diff --git a/CHANGELOG.md b/CHANGELOG.md index 40013aa..84c51f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # 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 `SITE-Chezlepro` n'avait jamais ete rase. Il avait ete monte par ajouts successifs, sur des diff --git a/docs/runbooks-exploitation.md b/docs/runbooks-exploitation.md index 76b5119..80db9f5 100644 --- a/docs/runbooks-exploitation.md +++ b/docs/runbooks-exploitation.md @@ -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 > 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. > 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. > > 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. +> +> **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 @@ -352,31 +364,48 @@ C'est de là que viennent onze des quinze murs. # 0. AVANT TOUT — l'état sort du bâtiment make depot-hors-site VERS= -# 1. Les VM, depuis l'underlay -make site-creer CONFIRMER=true # ~12 min pour 7 machines - -# 2. Le résolveur d'amorçage, DÉCLARÉ dans le plan du site +# 1. Le résolveur d'amorçage, DÉCLARÉ dans le plan du site AVANT de créer quoi que ce soit # plan/10-intrants.yml : dns_amorcage: # Sans lui, les machines pointent sur le DNS du site — qui est l'une d'elles. +# Et `nftables_admin_ssh: []`, sans quoi l'exploitant ne +# pourra pas atteindre ce qu'il vient de construire. -# 3. Le déploiement, DEUX FOIS au minimum -ansible-playbook -i scripts/site_inventaire.py playbooks/site.yml \ - -e "@$(dirname "$(readlink -f underlay.yml)")/underlay.vault.yml" +# 2. Les VM, depuis l'underlay ~9 min +make site-creer CONFIRMER=true -# 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,'!' -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 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`, 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. -**Deux passages, au minimum, et ce n'est pas un contournement.** `client_pki` pose les -droits d'une clé pour un consommateur qu'une couche ultérieure crée : au premier passage le -groupe `git` n'existe pas, la clé reste fermée, et Forgejo ne démarre qu'au second. +**Deux passages, et le second ne sert qu'à la forge.** Le premier va jusqu'à la couche 45 +sur 46 ; seul `serveur_ops` manque, faute de génome dans une forge neuve. + +> **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 @@ -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 | | 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 `.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 -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 créé par une couche ultérieure, une forge vide qu'on croit remplie. diff --git a/roles/serveur_forgejo/tasks/main.yml b/roles/serveur_forgejo/tasks/main.yml index 760173a..ccd515c 100644 --- a/roles/serveur_forgejo/tasks/main.yml +++ b/roles/serveur_forgejo/tasks/main.yml @@ -85,6 +85,34 @@ # 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 # 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 ? ansible.builtin.stat: path: "/usr/local/bin/forgejo-{{ serveur_forgejo_version }}" diff --git a/scripts/forge_amorcer.py b/scripts/forge_amorcer.py index 89b3ab0..c8b268e 100644 --- a/scripts/forge_amorcer.py +++ b/scripts/forge_amorcer.py @@ -36,6 +36,7 @@ import subprocess import sys import urllib.error import urllib.request +from urllib.parse import urlsplit from pathlib import Path RACINE = Path(__file__).resolve().parent.parent @@ -132,6 +133,35 @@ def main() -> int: f"Relancer avec : make forge-amorcer CONFIRMER=true") 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", "") if not mdp: print("\nREFUS : mot de passe de l'administrateur de la forge absent.\n" @@ -196,12 +226,36 @@ def main() -> int: continue # `--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. + # 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], - capture_output=True, text=True, timeout=180) + capture_output=True, text=True, timeout=180, env=env) if r.returncode == 0: print(f" {nom:22} pousse ({branche})") 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 print(f"\n{len(prets) - echecs}/{len(prets)} depot(s) amorces.")