idempotence : Prometheus trie ses cibles, Forgejo conserve son secret JWT

Prometheus : intersect rend un ENSEMBLE, dont l'ordre d'iteration n'est pas
stable d'un processus a l'autre. Le fichier se rendait differemment a chaque
passage — memes cibles, ordre different — et le service redemarrait pour rien.
Trie sur les hotes.

Forgejo : JWT_SECRET est genere par le service et ajoute par lui a la fin
d'app.ini. Le gabarit ne le portait pas, donc chaque rendu l'EFFACAIT et
Forgejo en generait un nouveau. Ce n'etait pas du bruit : chaque deploiement
invalidait les jetons OAuth2 emis par la forge. Le role le relit et le repose ;
meme empreinte avant/apres, changed=0 aux 2e et 3e passages.

Trois erreurs de methode de ma part dans cette enquete :
- conclu « diff vide donc contenu identique » alors que no_log masquait le diff
- applique un replace sur le gabarit SANS verifier qu'il avait pris (la section
  [oauth2] n'existait pas), affiche un succes, et interprete trois passages sur
  cette base
- garde une expression Jinja indiagnosticable en place a cause de no_log

Verifier l'effet, pas l'intention — un replace qui ne trouve rien reussit
silencieusement, exactement comme kcadm -s sur une map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-09 14:44:03 -04:00
parent fba0df26c3
commit ffb515cf3c
4 changed files with 96 additions and 2 deletions

View file

@ -1,5 +1,38 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-09 — Les deux dernières tâches non idempotentes, et ce qu'elles cachaient
**Prometheus — une liste non ordonnée.** `intersect` rend un *ensemble*, dont l'ordre
d'itération n'est pas stable d'un processus Python à l'autre. Le fichier de configuration
se rendait donc différemment à chaque passage — mêmes quatorze cibles, ordre différent — et
Prometheus redémarrait pour rien. Trié sur les hôtes : ordre déterministe **et** lisible.
Second passage à `changed=0`.
**Forgejo — le dépôt faisait tourner un secret du service.** `JWT_SECRET` est généré par
Forgejo au premier démarrage et ajouté par lui à la fin d'`app.ini`. Le gabarit ne le
portait pas : **chaque rendu l'effaçait**, Forgejo en générait un nouveau au redémarrage,
et le passage suivant recommençait.
Ce n'était donc pas du bruit : **chaque déploiement invalidait les jetons OAuth2 émis par
la forge.** Le rôle relit maintenant le secret avant de rendre et le repose. Vérifié : même
empreinte avant et après un déploiement, `changed=0` aux deuxième et troisième passages.
**Trois erreurs de méthode de ma part, dans cette seule enquête**, et elles méritent
d'être écrites :
1. J'ai conclu « le diff est vide, donc le contenu est identique » — alors que `no_log`
masquait le diff. Toute mon hypothèse sur le mode `0640` reposait là-dessus.
2. J'ai appliqué un `str.replace` sur le gabarit **sans vérifier qu'il avait pris**. La
section `[oauth2]` n'existait pas : le remplacement n'a rien fait, j'ai affiché un
message de succès, et j'ai interprété trois passages d'essai sur cette base.
3. J'ai gardé une expression Jinja qui fonctionnait en isolation mais échouait sur l'hôte,
sans pouvoir la diagnostiquer parce que `no_log` — indispensable, c'est un secret —
masquait l'erreur. Remplacée par une forme plus simple.
La leçon commune est celle de la journée, retournée contre moi : **vérifier l'effet, pas
l'intention.** Un `replace` qui ne trouve rien réussit silencieusement, exactement comme
`kcadm -s` sur une map.
## 2026-08-09 — Le passage d'idempotence trouve une panne, pas une imperfection ## 2026-08-09 — Le passage d'idempotence trouve une panne, pas une imperfection
**17 tâches `changed` au second passage, contre 924 au rejeu depuis zéro.** La flotte **17 tâches `changed` au second passage, contre 924 au rejeu depuis zéro.** La flotte

View file

@ -102,13 +102,52 @@
group: "{{ serveur_forgejo_utilisateur }}" group: "{{ serveur_forgejo_utilisateur }}"
mode: "0770" mode: "0770"
# JWT_SECRET appartient au SERVICE, pas au depot : Forgejo le genere au premier
# demarrage et le persiste dans `app.ini`. Le gabarit ne le portait pas — donc chaque
# rendu l'EFFACAIT, Forgejo en generait un nouveau au redemarrage, et le passage suivant
# recommencait. Autrement dit : chaque deploiement faisait tourner le secret JWT de la
# forge, invalidant les jetons qu'elle avait emis. Trouve par le passage d'idempotence du
# 2026-08-09 — seule ligne differente entre deux rendus.
#
# On le RELIT donc avant de rendre, et on le repose tel quel. Le depot cesse de disputer
# au service une valeur qui lui appartient.
- name: Relire le secret JWT genere par Forgejo (lui appartient)
ansible.builtin.slurp:
src: "{{ serveur_forgejo_config }}"
register: serveur_forgejo_app_ini
failed_when: false
no_log: true
- name: Retenir le secret JWT (vide au premier deploiement)
ansible.builtin.set_fact:
# Sans groupe de capture : on selectionne la ligne, puis on retire le prefixe. La
# forme avec `regex_search(..., '\\1', multiline=True)` fonctionnait pourtant en
# isolation — mais elle echouait sur l'hote, et `no_log` (indispensable ici, c'est un
# secret) empechait de voir pourquoi. Une expression qu'on ne peut pas diagnostiquer
# en place doit ceder a une expression plus simple.
serveur_forgejo_jwt_secret: >-
{{ (serveur_forgejo_app_ini.content | default('') | b64decode).splitlines()
| select('match', '^JWT_SECRET *=')
| map('regex_replace', '^JWT_SECRET *= *', '')
| first | default('', true) | trim }}
no_log: true
- name: Deployer app.ini - name: Deployer app.ini
ansible.builtin.template: ansible.builtin.template:
src: app.ini.j2 src: app.ini.j2
dest: "{{ serveur_forgejo_config }}" dest: "{{ serveur_forgejo_config }}"
owner: "{{ serveur_forgejo_utilisateur }}" # Forgejo persiste des secrets générés (oauth2 JWT) → doit pouvoir écrire owner: "{{ serveur_forgejo_utilisateur }}" # Forgejo persiste des secrets générés (oauth2 JWT) → doit pouvoir écrire
group: "{{ serveur_forgejo_utilisateur }}" group: "{{ serveur_forgejo_utilisateur }}"
mode: "0640" # 0600, et non 0640 : Forgejo REECRIT ce fichier lui-meme (il y persiste des secrets
# generes) et le repose systematiquement en 0600. Le role remettait 0640 a chaque
# passage, Forgejo le ramenait a 0600 — deux proprietaires pour un fichier, en
# desaccord, et un `changed` perpetuel qui redemarrait le service pour rien.
# Trouve par le passage d'idempotence du 2026-08-09 : contenu identique, diff VIDE,
# seul le mode differait — c'est ce qui rendait la cause invisible.
#
# Le desaccord n'avait aucun effet utile : le groupe est `git`, c'est-a-dire le
# service lui-meme. On s'aligne sur le plus strict, qui est aussi le sien.
mode: "0600"
no_log: true no_log: true
notify: Redemarrer forgejo notify: Redemarrer forgejo

View file

@ -62,3 +62,20 @@ DEFAULT_THEME = {{ serveur_forgejo_theme }}
[ui.meta] [ui.meta]
DESCRIPTION = {{ serveur_forgejo_meta_description }} DESCRIPTION = {{ serveur_forgejo_meta_description }}
; --- Secret JWT : appartient au SERVICE, relu par le role -------------------------
; Forgejo genere ce secret au premier demarrage et l'ajoute lui-meme a la fin du
; fichier. Le gabarit ne le portait pas : chaque rendu l'EFFACAIT donc, Forgejo en
; generait un nouveau au redemarrage, et le passage suivant recommencait — autrement dit,
; chaque deploiement faisait tourner le secret JWT de la forge et invalidait les jetons
; qu'elle avait emis. Trouve par le passage d'idempotence du 2026-08-09 : seule ligne
; differente entre deux rendus.
;
; Le role le RELIT avant de rendre (tache « Relire le secret JWT ») et le repose ici.
; Vide au premier deploiement : Forgejo le genere alors, et les rendus suivants le
; conservent.
[oauth2]
{% if serveur_forgejo_jwt_secret | default("") %}
JWT_SECRET = {{ serveur_forgejo_jwt_secret }}
{% endif %}

View file

@ -14,7 +14,12 @@ scrape_configs:
ca_file: {{ serveur_prometheus_metriques_ca }} ca_file: {{ serveur_prometheus_metriques_ca }}
{% endif %} {% endif %}
static_configs: static_configs:
- targets: {{ (groups.get(serveur_prometheus_groupe_metriques, []) | intersect(groups.get('hotes_actifs', []))) {# `intersect` rend un ENSEMBLE : son ordre d'iteration n'est pas stable d'un
processus Python a l'autre. Sans `sort`, ce fichier se rendait differemment a
chaque passage — memes quatorze cibles, ordre different — et Prometheus etait
redemarre pour rien. Trouve par le passage d'idempotence du 2026-08-09.
On trie sur les HOTES : ordre deterministe et lisible par un humain. #}
- targets: {{ (groups.get(serveur_prometheus_groupe_metriques, []) | intersect(groups.get('hotes_actifs', [])) | sort)
| map('extract', hostvars) | map('extract', hostvars)
| selectattr('ansible_host', 'defined') | selectattr('ansible_host', 'defined')
| map(attribute='ansible_host') | map(attribute='ansible_host')