❯ gitops · Running

Rendre les manifests à leur application : Applications ArgoCD multi-source et digest épinglé dans le dépôt de l'app Giving manifests back to their app: multi-source ArgoCD Applications and a digest pinned in the app's own repo

Pendant des mois, chaque build de CI s'est terminé par un commit de robot dans le dépôt d'infrastructure : 141 de ses 487 commits. Le 2 octobre 2026, cinq apps ont rapatrié leurs manifests dans un dossier deploy/ de leur propre dépôt, lu par ArgoCD à côté du dépôt d'infra. Comment la bascule s'est faite sans redémarrer un pod, et les pièges rencontrés en route. For months, every CI build ended with a robot commit in the infrastructure repo: 141 of its 487 commits. On 2 October 2026, five apps moved their manifests into a deploy/ folder in their own repo, read by ArgoCD next to the infra repo. How the switch went without restarting a pod, and the traps found on the way.

· 9 min de lecture 9 min read ·
gitopsargocdcisealed-secretsk3s-clusterk8s
🇬🇧 Read this article in English

Le constat : un dépôt d’infra écrit à 29 % par des robots

Le 2 octobre 2026, je compte les auteurs des commits du dépôt d’infrastructure du cluster, celui qu’ArgoCD surveille. Sur 487 commits, 141 ne viennent pas d’un humain : 97 signés par le robot de Gitea Actions, 44 par celui de GitHub Actions, du temps où la CI tournait encore là-bas. Tous disent la même chose, à la formulation près : « pin image digest » ou « bump image ».

C’est le prix du modèle « GitOps cross-repo » décrit dans un article précédent. Le code d’une app vit dans son dépôt, ses manifests vivent dans le dépôt d’infra. Après chaque build, la CI de l’app clone le dépôt d’infra, réécrit la ligne image: avec un sed, commite et pousse. Ça marche. Mais trois choses me gênaient de plus en plus :

  • Le bruit. Un git log du dépôt d’infra, c’est un relevé de builds entrecoupé de vraies décisions. Retrouver « quand ai-je changé la politique de sauvegarde ? » demande un --author d’exclusion.
  • Le jeton. La CI de chaque app détient un jeton capable d’écrire dans le dépôt qui décrit tout le cluster. Le principe du moindre privilège dirait plutôt l’inverse.
  • La course. Deux builds qui finissent en même temps poussent sur la même branche, et l’un des deux se fait refuser son push. Le pas de CI ne prévoyait aucune reprise : un cas rare, donc jamais traité.

Le cas le plus caricatural était un petit générateur HTML d’une seule page : 454 Ko de HTML recopiés à la main dans une ConfigMap du dépôt d’infra, sans CI, à près de la moitié de la limite d’1 Mio d’etcd par objet.

L’idée : le dépôt d’infra garde l’Application et les secrets, l’app garde le reste

Le nouveau partage est simple :

  • le dépôt de l’app porte son code, son Dockerfile et un dossier deploy/ (namespace, Deployment, Service, Ingress). Sa CI y épingle elle-même le digest de l’image qu’elle vient de construire ;
  • le dépôt d’infra garde la définition de l’Application ArgoCD et ce qui doit rester centralisé : le SealedSecret qui permet de tirer l’image du registre privé.

Pourquoi garder ce secret côté infra ? Parce qu’un seul jeton de registre sert à huit apps. Quand il tourne, il faut resceller huit SealedSecrets, et je préfère le faire en un commit dans un dépôt plutôt qu’en huit commits dans huit dépôts.

Pour qu’ArgoCD lise les deux endroits, il faut une Application multi-source (spec.sources, disponible depuis ArgoCD 2.6) :

spec:
  sources:
    - repoURL: http://<gitea-interne>/<org>/<app>.git
      targetRevision: main
      path: deploy
    - repoURL: http://<gitea-interne>/<org>/k3s-homelab.git
      targetRevision: actual-state
      path: <app>          # ne contient plus que sealedsecret-ghcr-pull.yaml
  destination:
    server: https://kubernetes.default.svc
    namespace: <app>

ArgoCD rend les deux chemins et applique l’union. Pour lui, c’est un seul état désiré.

Le prérequis : un credential à préfixe plutôt qu’un credential par dépôt

Jusque-là, ArgoCD ne lisait qu’un dépôt privé, celui d’infra, avec un credential dédié. Cinq dépôts de plus voulaient dire cinq Secrets de plus, et un sixième le jour où une nouvelle app arrive.

ArgoCD sait faire mieux : un Secret étiqueté argocd.argoproj.io/secret-type: repo-creds s’applique à tous les dépôts dont l’URL commence par son préfixe. Un seul credential, avec un jeton en lecture seule, pour http://<gitea-interne>/<org>/, et chaque nouveau dépôt de l’organisation est lisible sans rien ajouter.

Un détail d’ordre : ce credential vit dans un dossier de bootstrap qui n’est pas lui-même géré par ArgoCD. C’est voulu. ArgoCD ne peut pas aller chercher le credential qui lui permet de lire un dépôt dans ce même dépôt. Il est donc appliqué à la main, une fois, et noté comme tel dans la doc.

Au passage, j’ai trouvé dans le namespace d’ArgoCD une clé SSH privée vers l’ancien dépôt GitHub, d’avant la migration vers Gitea, qu’aucune Application ne référençait plus. Elle est partie le même jour.

La méthode : copier à l’identique, vérifier à blanc, basculer, puis seulement la CI

La première app, le pilote, a servi à fixer un ordre que j’ai ensuite répété quatre fois :

  1. Copier les manifests dans deploy/ du dépôt de l’app, octet pour octet. Aucune « amélioration » pendant le déménagement : un diff vide est la seule preuve qu’on n’a rien cassé.
  2. Exclure deploy/ de l’image (.dockerignore). Pour un site statique servi par nginx, oublier cette ligne revient à publier ses manifests à l’adresse /deploy/deployment.yaml.
  3. Vérifier à blanc avec une Application temporaire sans finalizer, qui pointe vers le nouveau dossier. Sans finalizer, la supprimer ne supprime rien dans le cluster. On regarde seulement ce qu’ArgoCD en rend : 5 ressources, aucune ComparisonError.
  4. Basculer l’Application réelle vers sources:. Comme les fichiers sont identiques, ArgoCD ne voit aucune différence : la generation du Deployment est restée à 53, aucun pod recréé.
  5. Seulement ensuite, modifier la CI pour qu’elle épingle le digest dans deploy/ au lieu du dépôt d’infra.

Le premier build après la bascule a fait le tour complet : image construite, digest épinglé dans deploy/deployment.yaml par la CI, ArgoCD synchronisé sur cette révision, generation 54, deux nouveaux pods, site en 200, et /deploy/ en 404. Le dépôt d’infra n’a reçu aucun commit.

Le pilote a basculé à 13 h 31, la doc du patron a suivi à 13 h 52, et les quatre autres apps ont suivi dans l’après-midi. La dernière a basculé à 16 h 05.

Le blog lui-même : 125 fichiers et 71 commits qui déménagent

Le blog que vous lisez faisait partie des cinq, et c’était le cas le plus lourd. Son code Astro entier (125 fichiers : composants, articles, Dockerfile, configuration nginx) vivait encore dans un sous-dossier du dépôt d’infra, avec un filtre pour qu’ArgoCD l’ignore.

Pour ne pas perdre l’historique, le déménagement est passé par git subtree split : les 71 commits qui touchaient ce sous-dossier depuis mai 2026 forment l’historique du nouveau dépôt. Le workflow de CI est parti avec le code. Avant la bascule, une vérification de plus : le digest déjà épinglé dans le nouveau deploy/ était celui qui tournait en production. La bascule s’est donc faite sans redémarrage.

Le pas de CI qui épingle le digest n’a presque pas changé. Il ne clone simplement plus rien :

- name: Pin digest in deploy/deployment.yaml
  run: |
    set -eu
    sed -i -E "s|image: ghcr.io/<compte>/blog@sha256:[a-f0-9]+|image: ghcr.io/<compte>/blog@${DIGEST}|" deploy/deployment.yaml
    git diff --quiet deploy/deployment.yaml && { echo up-to-date; exit 0; }
    git commit -am "chore(deploy): pin image digest [skip ci]"
    git push "<url-du-depot-de-l-app>" HEAD:main

Le piège : le commit qui épingle ne doit pas relancer le build

Dès que la CI commite dans son propre dépôt, une boucle devient possible : build, commit du digest, nouveau push, nouveau build, nouveau digest, et ainsi de suite. Deux protections, volontairement redondantes :

  • le marqueur [skip ci] dans le message du commit d’épinglage ;
  • le filtre paths: du workflow, qui ne liste que src/**, public/**, le Dockerfile, etc. deploy/** n’y figure pas, donc un commit qui ne touche que deploy/ ne déclenche rien, marqueur ou pas.

Et le piège inverse, rencontré le jour même : Gitea cherche le marqueur dans tout le message, corps compris. J’ai écrit un commit de documentation dont le corps expliquait que la CI utilise [skip ci]. Résultat : aucun build. Il a fallu relancer le run à la main. Depuis, la consigne est écrite en tête du workflow : ne jamais citer ce marqueur dans un message de commit, même pour en parler.

Le bilan, deux semaines plus tard

Entre la dernière bascule et le 15 octobre, le dépôt d’infra a reçu 151 commits. Aucun ne vient d’un robot de CI. Côté blog, 8 des 16 commits de la même période sont des épinglages de digest, là où ils doivent être : dans l’historique de l’app qu’ils concernent.

Le générateur HTML est devenu une vraie image nginx construite par la CI de son dépôt. La ConfigMap de 454 Ko a disparu, avec elle le copier-coller à la main.

Ce que le patron coûte, pour être honnête :

  • Deux endroits à regarder. L’état d’une app dépend de deux branches dans deux dépôts. Le commentaire en tête de chaque Application dit lequel contient quoi, et c’est devenu indispensable.
  • Le credential de bootstrap reste un geste manuel, à refaire si son jeton tourne.
  • Huit SealedSecrets de registre à resceller à chaque rotation. Centralisés, mais huit quand même.
  • Une app est restée à l’ancien modèle : sa suite de 43 tests lit les manifests depuis le dépôt d’infra. Son prochain build écrira donc encore dans le dépôt d’infra. La déplacer est un chantier à part.

Ce que je retiens

  1. Le dépôt qui produit l’artefact écrit sa référence. Une CI qui pousse dans le dépôt d’un autre, c’est un couplage qu’on finit par payer en bruit, en droits trop larges ou en conflits de push.
  2. Le dépôt d’infra garde ce qui doit rester central : les Applications, les secrets partagés, le bootstrap. Pas les manifests d’une app qui a sa propre vie.
  3. Un credential repo-creds à préfixe évite d’ajouter un Secret par dépôt. Il faut juste accepter qu’il vive hors d’ArgoCD.
  4. Migrer sans rien changer. Copie octet pour octet, Application temporaire sans finalizer, bascule, et la CI en dernier. Si la generation ne bouge pas, la bascule est réussie.
  5. Deux garde-fous contre la boucle de build, marqueur et filtre de chemins. Et ne jamais citer le marqueur dans un message de commit.
gitopsargocdcisealed-secretsk3s-clusterk8s