Contexte : la bascule de la source de vérité vers un Gitea self-hosté est racontée dans Migrer un homelab GitOps de GitHub vers Gitea self-hosted. Cet incident est ce qui arrive trois mois plus tard, quand on oublie que la migration ne s’arrête pas au jour de la bascule.
Le symptôme : deux refs qui ne se ressemblent pas
Le 13 août 2026, après un push de routine sur la source de vérité GitOps, réflexe de vérification : est-ce que le backup a bien reçu la copie ? La question paraît rhétorique. Le push-mirror est configuré en sync_on_commit, avec un repli toutes les 8 heures. Il n’y a rien à surveiller.
git ls-remote <source-de-verite> refs/heads/actual-state
# a3f9c21… refs/heads/actual-state
git ls-remote <backup> refs/heads/actual-state
# 7d14e08… refs/heads/actual-state
Deux SHA différents. Première réaction raisonnable : de la latence. Le mirror n’a pas encore tourné, la copie arrive dans quelques secondes.
Sauf que non. Le commit 7d14e08 côté backup n’est pas un ancêtre de a3f9c21. Le backup n’est pas en retard sur la source : il a divergé. Il contient du contenu que la source n’a jamais eu.
Le contexte : une seule direction, en théorie
L’architecture, sur le papier, tient en une phrase. Le dépôt Gitea self-hosté est la source de vérité. Le déployeur GitOps le lit en continu, avec selfHeal et prune activés — tout ce qui est dans le dépôt existe dans le cluster, tout ce qui n’y est plus est supprimé du cluster. Un push-mirror recopie la source vers un dépôt de backup off-site, dans cette direction uniquement.
Une seule direction, un seul writer, un seul lecteur. C’est exactement le genre d’invariant qu’on cesse de vérifier au bout de quelques semaines, parce qu’il est vrai par construction.
Il l’était. Jusqu’à ce qu’il cesse de l’être, sans que rien ne le signale.
L’investigation : qualifier la divergence avant de la corriger
Un git ls-remote dit que deux refs diffèrent. Il ne dit pas comment. La question qui compte, avant toute action, est : est-ce que du contenu existe d’un seul côté, et si oui, lequel ?
C’est ce que merge-base répond. On récupère les deux refs dans un clone local, puis on compte de chaque côté du point de divergence :
git fetch source actual-state
git fetch backup actual-state
git merge-base source/actual-state backup/actual-state
# c8b2f45… ← le dernier ancêtre commun
git rev-list --left-right --count source/actual-state...backup/actual-state
# 3 2
Trois commits présents uniquement côté source. Deux commits présents uniquement côté backup. La divergence est bilatérale, et c’est le second chiffre qui fait mal : deux commits que la source de vérité n’a jamais vus, donc que le déployeur n’a jamais lus, donc qui n’ont jamais été appliqués au cluster.
git log --oneline source/actual-state..backup/actual-state
# 7d14e08 chore: remove static site (2)
# 9a02f31 chore: remove static site (1)
Deux suppressions de sites statiques. Datées de cinq jours plus tôt. Auteur : un compte de service.
Les sites en question tournaient toujours dans le cluster. Ils tournaient même particulièrement bien : le selfHeal du déployeur, lisant une source de vérité où ils étaient toujours déclarés, les avait maintenus en vie avec application pendant cinq jours. Quelqu’un — quelque chose — avait demandé leur suppression, et le système avait consciencieusement fait le contraire.
Cause 1 : un writer que plus personne n’attendait
Le compte de service pointait vers une petite webapp d’administration maison, écrite bien avant la migration. Son rôle : gérer un catalogue de sites statiques, et committer les changements directement dans le dépôt GitOps. Elle le faisait via une deploy key SSH configurée à l’époque où le dépôt de backup était encore la source de vérité.
La migration avait déplacé la source de vérité. Elle n’avait pas déplacé cette webapp. Personne n’y avait pensé, parce que personne ne l’utilisait — sauf, précisément, cinq jours plus tôt.
Le résultat est plus vicieux qu’un simple oubli de configuration : la webapp fonctionnait parfaitement. Elle authentifiait, elle committait, elle poussait, elle recevait un accusé de réception. De son point de vue, la suppression était faite. Rien dans son interface ne pouvait indiquer qu’elle écrivait désormais dans un dépôt qui n’a plus aucun lecteur. Un writer résiduel après migration ne tombe pas en panne : il continue de réussir, dans le vide.
Cause 2 : un token qui a expiré en silence
Reste la question du mirror. S’il fonctionnait, il aurait dû écraser ces deux commits depuis longtemps. Gitea garde l’état de chaque push-mirror en base, dans une table dédiée. Une requête suffit à voir l’ensemble du parc :
SELECT repo_id, remote_name, sync_on_commit,
to_timestamp(last_update) AS last_update,
last_error
FROM push_mirror
ORDER BY last_update ASC;
Sur 18 push-mirrors configurés, 16 avaient synchronisé dans les minutes précédentes. Deux affichaient un last_update figé au même jour — cinq jours plus tôt — et un last_error sans ambiguïté :
last_error: Invalid username or token. ...
Le credential embarqué dans ces deux mirrors avait expiré. Aucune notification, aucune alerte : un push-mirror qui échoue le note dans sa ligne de table et réessaie au prochain cycle. Le backup était gelé depuis cinq jours, et la seule trace de cet état vivait dans une colonne que personne ne regardait.
La page des tokens côté backup a confirmé le diagnostic en donnant, au passage, l’explication du contraste : le token utilisé par les 16 mirrors sains était un token sans date d’expiration ; celui des deux mirrors cassés avait été créé avec une durée de vie, et l’avait atteinte.
poussés après le gel du mirror — jamais recopiés vers le backup pushed after the mirror froze — never copied over to the backup
écrits par le writer résiduel — jamais lus par le déployeur written by the residual writer — never read by the deployer
Et là se trouve l’ironie de cet incident. Les deux pannes se compensaient. Un mirror sain aurait force-poussé le contenu de la source par-dessus le backup à chaque commit, et aurait effacé les deux commits de la webapp au fur et à mesure. Les sites auraient continué de tourner exactement pareil, la webapp aurait continué d’échouer en silence, et rien n’aurait jamais révélé qu’un second writer existait. C’est la panne du mirror qui a conservé la preuve.
La remédiation : l’ordre avant tout
Deux problèmes indépendants, deux réparations évidentes. Le piège est qu’elles ne commutent pas.
Réparer le mirror en premier semble naturel — c’est la panne « technique », celle qui a un message d’erreur. Ce serait une destruction de données. Un push-mirror ne fusionne pas : il force la ref distante à correspondre à la ref locale. La toute première synchronisation d’un mirror réparé aurait écrasé le backup avec le contenu de la source, et les deux commits orphelins auraient disparu — cette fois définitivement, sans qu’aucun merge-base ne puisse plus les retrouver.
réconcilier reconcile
les 2 commits orphelins sont rapatriés dans la source de vérité — le backup n’est pas touché the 2 orphaned commits are brought back into the source of truth — the backup isn’t touched
~10 s plus tard ~10s later
l’intention vieille de 5 jours devient enfin réelle : les 2 sites sont supprimés du cluster the 5-day-old intent finally becomes real: the 2 sites are removed from the cluster
delete + recreate delete + recreate
le credential d’un mirror existant n’est pas éditable — il faut le supprimer puis le recréer, avec un token sans expiration an existing mirror’s credential can’t be edited — it has to be deleted then recreated, with a token that never expires
en conditions réelles under real conditions
un commit de test, puis comparaison des refs des deux côtés — pas seulement un code retour d’API a test commit, then comparing the refs on both sides — not just an API return code
un seul writer a single writer
deploy key révoquée, puis décommission complète de l’application qui l’utilisait deploy key revoked, then full decommissioning of the app that used it
L’ordre appliqué a donc commencé par la réconciliation :
1. Rapatrier les commits orphelins dans la source de vérité. Un cherry-pick des deux commits de suppression depuis la ref de backup vers la branche suivie par le déployeur, puis un push sur la source. Le backup n’est pas touché à cette étape.
git cherry-pick 9a02f31 7d14e08
git push source actual-state
2. Laisser le déployeur faire son travail. Une dizaine de secondes plus tard, l’intention vieille de cinq jours devenait enfin réelle : les deux sites statiques étaient supprimés du cluster. C’est le seul moment de l’incident où le système a fait exactement ce qu’on lui demandait.
3. Recréer le push-mirror. Détail d’implémentation qui coûte cinq minutes de recherche : Gitea ne permet pas de modifier le credential d’un push-mirror existant. Il faut supprimer l’entrée et la recréer, cette fois avec un token dédié sans date d’expiration.
4. Vérifier en conditions réelles. Un commit de test sur la source, puis un git ls-remote des deux côtés pour comparer les refs. Pas un code retour d’API, pas un statut affiché dans une interface : les SHA eux-mêmes. La leçon est déjà connue par ici — lors de la migration initiale, un mirror-sync répondait 200 tout en ne recopiant rien.
5. Couper le second writer. Révocation de la deploy key de la webapp, puis décommission complète de l’application elle-même — elle ne servait plus depuis la migration, et la garder revenait à garder une porte d’écriture ouverte sur un dépôt critique.
Leçons retenues
1. Un mirror casse en silence, par conception
Un push-mirror est une pièce d’infrastructure sans utilisateur. Personne ne s’en plaint quand il tombe, parce que personne ne lit jamais sa sortie — c’est précisément son rôle d’être inutile jusqu’au jour où il ne l’est pas. La seule protection est de surveiller son état à la source. Le champ last_error de la table des mirrors est exportable ; une alerte sur « au moins un mirror en erreur depuis plus d’une heure » aurait transformé cinq jours d’incident invisible en une notification le premier soir.
2. Un token avec expiration est une bombe à retardement, pas une bonne pratique
Sur un poste de travail ou un CI, faire expirer un credential est sain : quelqu’un le renouvellera, parce que quelqu’un remarquera la panne. Sur un composant que personne n’observe, l’expiration produit exactement l’inverse de la sécurité recherchée — un backup silencieusement mort. Deux options défendables : un token sans expiration, inventorié et rotationné volontairement ; ou une expiration assumée, mais accompagnée d’une alerte de fin de vie. La troisième option — une expiration posée par défaut et oubliée — est la seule qui ne protège de rien.
3. Un dépôt, un writer
L’invariant qui a réellement cassé n’est pas le mirror, c’est celui-ci. Dès qu’un second acteur peut écrire dans un dépôt qui participe à une chaîne de synchronisation, il existe un scénario où son travail est écrasé ou ignoré. La règle est simple à énoncer et facile à laisser s’éroder : tout ce qui écrit, écrit dans la source de vérité. Un service qui doit committer pointe la source, jamais un miroir, jamais un backup.
4. Une migration ne se termine pas le jour de la bascule
Repointer les lecteurs est la partie visible : elle échoue bruyamment si on l’oublie. Les writers, eux, ne se plaignent jamais. Après une migration de source de vérité, il faut faire l’inventaire explicite de tout ce qui possède un droit d’écriture — deploy keys, tokens de CI, webhooks, comptes de service, applications maison — et les repointer ou les révoquer un par un. La liste des clés de déploiement d’un dépôt est un bon point de départ : chaque entrée dont on ne sait pas dire à quoi elle sert aujourd’hui est un writer résiduel en puissance.
5. Réconcilier avant de réparer le canal
C’est la leçon transposable au-delà de git. Face à deux répliques divergentes et à un canal de synchronisation en panne, la tentation est de réparer le canal — c’est la panne qui a un message d’erreur. Mais un canal de synchronisation restauré propage, il n’arbitre pas : il transforme la divergence en écrasement, dans la direction que sa configuration impose. Tant que du contenu n’existe que du mauvais côté, réparer la synchronisation est une suppression déguisée. On qualifie la divergence, on réconcilie, et seulement ensuite on remet le canal en service.
Runbook : vérifier une chaîne de mirrors
À faire une fois, puis à automatiser :
git ls-remotesur chaque remote de la chaîne, comparer les SHA — pas les statuts d’API.- Si divergence :
git merge-basepuisgit rev-list --left-right --countpour savoir de quel côté vit le contenu orphelin. Ne rien réparer avant d’avoir cette réponse. - Interroger l’état des mirrors à la source (
last_update,last_error) et vérifier qu’aucun n’est figé. - Lister les credentials d’écriture de chaque dépôt et confirmer que chacun a un propriétaire identifié et une date d’expiration connue.
- Après toute réparation d’un canal de sync, faire un commit de test et comparer les refs des deux côtés.