Compare commits

...
Author SHA1 Message Date
arcodange a941ec3f1c Merge pull request 'feat(argocd) — enregistrer kadans-admin, compagnon sans état de kadans' (#56) from arcodange/enroll-kadans-admin into main 2026-08-15 12:22:26 +02:00
arcodange d93fa9c8ae feat(argocd) — enregistrer kadans-admin, compagnon sans état de kadans
Backoffice de modération (ADR kadans-dossier 0018) : le squelette et son
chart sont mergés côté kadans-admin depuis le 03/08, mais rien ne
l'enregistrait ici — aucune Application, rien de vivant sur le cluster.

Compagnon « sans état » au sens du runbook (09-service-compagnon.md),
même motif que kadans-jobs : ni base ni secret Vault propres, juste le
namespace partagé `kadans`. La sécurité applicative vit côté kadans-api
(requireAdmin, KADANS_ADMIN_EMAILS — déjà câblé là-bas).

⚠ Merger APRÈS que l'image kadans-admin:latest existe réellement sur le
registre (kadans-admin!wire-deploiement, le workflow qui la construit) —
sinon la première synchro tire dans le vide (ImagePullBackOff), le même
piège déjà payé une fois côté kadans-jobs.
2026-08-11 17:16:13 +02:00
arcodange 80532ed9b4 Merge pull request 'fix(k3s) — kubelet-arg guillemeté cassait le parsing (pi1 exposé), + réservation pi2' (#55) from arcodange/pi2-kubelet-reserved into main 2026-08-11 17:08:36 +02:00
arcodange d355c9c24e fix(k3s) — kubelet-arg guillemeté cassait le parsing, + réservation pi2
Le scheduler k8s croyait disposer des 4 cœurs / 7,6 Gi entiers de pi2,
alors que Gitea et Postgres (docker compose nu, hors k3s, PR factory#54)
en consomment une part invisible. `--kubelet-arg="system-reserved=cpu=2,
memory=2Gi"` sur l'agent pi2 corrige ça — réservation informative, pas
d'--enforce-node-allocatable, donc pas de nouvelle éviction.

En le déployant : incident réel. `--kubelet-arg="k=v"` (guillemets
littéraux autour de key=value) ressort en `\=` littéral dans l'ExecStart
que k3s-install.sh régénère — kubelet refuse de démarrer ("unknown flag:
--container-log-max-files\"), boucle de redémarrage jusqu'à NotReady.
C'était déjà le cas pour les DEUX args pré-existants (container-log-max-
files, container-log-max-size), latent depuis des mois parce que
k3s-agent n'avait pas redémarré depuis avril — jamais régénéré par la
version actuelle du script. Mon changement a déclenché le premier
restart réel et l'a fait sortir.

pi2 a été NotReady ~3 min pendant le diagnostic puis la correction en
direct (aucun pod évincé, sous le pod-eviction-timeout par défaut de
5 min — vérifié). Les DEUX occurrences pré-existantes sont corrigées ici
aussi (extra_server_args ET extra_agent_args), pas seulement la mienne :
pi1 (le control-plane) porte le MÊME bug dans sa source, dormant parce
que son k3s.service n'a pas non plus redémarré récemment. Sans cette
PR, le prochain restart de pi1 (reboot, ou un futur run de ce playbook)
aurait cassé l'API server de la même façon.

Le format sans guillemets (`--kubelet-arg=k=v`) traverse la génération
intact — vérifié par la correction en direct sur pi2 (journal confirme
`--system-reserved=cpu=2,memory=2Gi` sans backslash, service stable,
Allocatable descendu de 4 cœurs/8Gi à 2 cœurs/5,6Gi).
2026-08-11 17:05:18 +02:00
arcodange adc07f91c3 Merge pull request 'fix(pi2) — plafonner Gitea et Postgres (docker compose, hors k3s)' (#54) from arcodange/pi2-resource-limits into main 2026-08-11 16:26:24 +02:00
arcodange f250817641 fix(pi2) — Gitea et Postgres avaient zéro plafond, plus maintenant
pi2 tournait à load average ~45 (4 cœurs) pendant qu'un push docker
timeoutait vers le registre. Gitea et Postgres tournent en docker compose
nu, hors k3s — invisibles du scheduler ET sans limite (`docker inspect`
mesurait NanoCPUs=0, Memory=0 pour les deux), donc rien ne les empêchait
de se battre à armes égales avec tout le reste du nœud.

Postgres → 1 CPU / 1024M, Gitea → 1.5 CPU / 1536M (Compose v2 honore
`deploy.resources.limits` hors swarm). Valeurs dérivées d'une mesure au
repos (Postgres 3-5 %, Gitea 12 % CPU) avec de la marge pour les pics —
un filet, pas un dimensionnement pour la charge normale.

Appliqué et vérifié en direct sur pi2 : les deux conteneurs ont recréé
avec les nouvelles limites (`docker inspect` confirme), PostGIS survit
au recreate de Postgres (déjà géré par ce playbook), Gitea sert web (200)
et registre (401 attendu, anonyme) normalement après coup.

Le levier complémentaire (kubelet --system-reserved/--kube-reserved sur
pi2, pour que le SCHEDULER k8s sache que cette place est déjà prise) n'est
pas dans cette PR — plus gros, touche system_k3s.yml pour tout le cluster.
2026-08-11 16:25:20 +02:00
arcodange 1520ecac41 Merge pull request 'feat(postgres) — PostGIS, posé par le playbook et non par une image custom' (#52) from arcodange/postgis-pour-kadans into main 2026-08-08 11:59:30 +02:00
arcodangeandClaude Opus 5 f944fe4bbd feat(postgres) — PostGIS, posé par le playbook et non par une image custom
Kadans doit ranger le contour d'un quartier en vraie géométrie
(geometry(MultiPolygon,4326), ST_Contains, index GiST). L'extension
n'existait nulle part : mesuré sur pi2, `pg_available_extensions` ne
rendait AUCUNE ligne `postgis%`.

Arbitrage fondateur (2026-08-08) : on garde `postgres:16.3-alpine` et on
pose l'extension par Ansible, comme le playbook pose déjà les bases et le
rôle pgbouncer. Pas d'image custom.

⚠ POURQUOI LE RECALAGE DE CHEMINS N'EST PAS FACULTATIF — mesuré, arm64.
`apk add postgis` SEUL réussit, et `CREATE EXTENSION postgis` échoue quand
même :

    ERROR: extension "postgis" is not available
    DETAIL: Could not open extension control file
            "/usr/local/share/postgresql/extension/postgis.control"

Le paquet Alpine vise la disposition d'Alpine (/usr/share/postgresql16,
/usr/lib/postgresql16) ; l'image officielle compile le serveur dans
/usr/local. Les fichiers sont là, le serveur regarde ailleurs. Après
recalage : PostGIS 3.4 USE_GEOS=1 USE_PROJ=1, et un polygone lyonnais qui
fait l'aller-retour ST_GeomFromText → ST_AsGeoJSON.

Ne pas « simplifier » en un `apk add` nu : la simulation dit OK,
l'installation dit OK, et l'extension reste inutilisable.

⚠ INSTALLATION PAR CONTENEUR, PAS PAR VOLUME. `apk add` écrit dans la
couche inscriptible : recréer le conteneur efface PostGIS pendant que les
données gardent leurs colonnes géométriques — toute requête spatiale casse
jusqu'au prochain passage du playbook. D'où l'ordre (déploiement compose
PUIS installation), l'idempotence, et surtout la tâche de vérification.

La vérification ne se contente pas d'un code de retour : elle exige que la
base rende USE_GEOS=1 ET un vrai Point GeoJSON avec son SRID. Un bouchon
qui répondrait une chaîne vide passerait un simple `rc == 0` et ne
prouverait rien — un playbook vert sur une extension absente ferait
atterrir le symptôme dans Kadans, des jours plus tard, déguisé en bug
applicatif.

Vérifié sur le conteneur RÉEL sans le modifier : `apk add --simulate`
résout postgis 3.4.2-r2, et `pg_config` y rend bien les deux chemins que
les variables supposent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01J4UE4AmX5PAMN6c6Q6Fey9
2026-08-08 09:46:08 +02:00
arcodange b7f7a47a5d Merge pull request 'feat(miroirs) — un dépôt personnel n'est pas une organisation' (#47) from arcodange/mirror-depots-perso into main 2026-07-30 19:55:31 +02:00
arcodange 0cc8213bff Merge remote-tracking branch 'origin/main' into arcodange/mirror-depots-perso
# Conflicts:
#	ansible/arcodange/factory/inventory/group_vars/all/gitea.yml
2026-07-30 19:55:06 +02:00
arcodange 5ad6601c01 Merge pull request 'fix(cicd) — épingler la version du runner : latest + pull: missing ne rafraîchit JAMAIS' (#51) from arcodange/runner-version-epinglee into main 2026-07-30 09:10:57 +02:00
CI BotandClaude Opus 5 6bb27b0e5c feat(cicd) — Gitea 1.27.1 et Gitea Runner 2.3.0 : l'image du runner a CHANGÉ DE NOM
⚠ CORRIGE LE PREMIER JET DE CETTE BRANCHE, qui épinglait `gitea/act_runner:0.3.1`.

`gitea/act_runner` est GELÉE à 0.6.1. Le successeur officiel est `gitea/runner`
(binaire renommé `act_runner` → `gitea-runner`), aujourd'hui en **2.3.0**.
Épingler l'ancien nom nous aurait enfermés dans une image morte — trouvé grâce
aux notes de version de Gitea 1.27 signalées par le fondateur.

VÉRIFIÉ AVANT DE BASCULER — c'est un remplacement DIRECT pour ce compose :
  • entrypoint identique : /sbin/tini -- run.sh
  • mêmes variables lues : CONFIG_FILE, GITEA_INSTANCE_URL,
    GITEA_RUNNER_{REGISTRATION_TOKEN,NAME,LABELS}
  • config.yaml compatible : capacity, labels, cache.*, container.force_pull,
    options, valid_volumes, host.workdir_parent — AUCUNE clé utilisée ici n'a
    disparu (comparé au `gitea-runner generate-config` de la 2.3.0)

La 2.3.0 apporte en prime des réglages qui parlent à nos pannes connues :
`health_check.min_free_disk_space_mb` (les images de runner supprimées quand le
disque se remplit, ADR 20260407) et `state_report_interval` (les tâches tuées en
zombie faute de rapport, factory#50).

GITEA 1.25.5 → 1.27.1 : deux versions mineures, migrations de base
IRRÉVERSIBLES. Sauvegardes du jour VÉRIFIÉES avant, pas supposées :
  /mnt/backups/postgres/backup_20260730.sql.gz  13 Mo, gzip -t OK,
      contient « CREATE DATABASE gitea » (pg_dumpall)
  /mnt/backups/gitea/backup_20260730.gitea.gz   1,7 Go, gzip -t OK
⚠ Le backup Gitea utilise `gitea dump --skip-db` : il ne contient PAS la base.
C'est le dump postgres qui la porte — les deux sont nécessaires.

Changements cassants de 1.27 et leur portée ici, vérifiée :
  • workflows réutilisables externes retirés → AUCUN dans front, kadans-api,
    factory (contrôlé programmatiquement, `uses:` au niveau job)
  • nonce CSP pour scripts inline → concerne les templates personnalisés, nous
    n'en avons pas
  • X-Content-Type-Options: nosniff par défaut

Refs arcodange-org/factory#50

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 09:03:40 +02:00
CI BotandClaude Opus 5 48e3d6827c fix(cicd) — épingler la version du runner : latest + pull: missing ne rafraîchit JAMAIS
Réponse à « qu'est-ce qui nous empêche d'upgrade des deux côtés ? » : rien.
Le playbook déployait `gitea/act_runner:latest` avec `pull: missing`, c'est-à-dire
la pire combinaison possible — un tag FLOTTANT qui n'est JAMAIS rafraîchi. Chaque
hôte garde donc ce que « latest » voulait dire le jour de son premier pull :

  pi1 : sha256:7bdc8d31…  →  v0.3.1
  pi3 : sha256:0f65fa10…  →  v0.2.13

Deux machines censées être équivalentes, deux versions à trois mineures d'écart.
Effets mesurés : le MÊME job, sur la MÊME image de CI, met 511 s sur pi1 et
397 s sur pi3 (114 s d'écart imputables à la machine) ; et pi3 a mal lu la
définition d'un job dont il dépendait (« 'runs-on' key not defined », puis
« No steps found »).

⚠ POURQUOI PAS `latest` + `pull: always`. `latest` vaut aujourd'hui **0.6.1**
(Docker Hub, 30/04/2026), soit 3 à 4 versions mineures devant tout ce qui est
éprouvé ici. Le runner exécute TOUTE la CI de la forge : une montée subie, non
datée et non choisie s'y paie cher. On épingle donc, et on monte délibérément.

⚠ POURQUOI 0.3.1 ET PAS 0.6.1. 0.3.1 est la version que pi1 exécute DÉJÀ avec
succès sur cette forge. Ce changement aligne donc pi3 VERS LE HAUT, sur du
prouvé, sans saut de quatre versions. Passer ensuite à 0.6.1 devient une
modification d'UNE ligne, datée et reculable — c'est tout l'intérêt de la
variable.

⚠ Et `pull: missing` redevient CORRECT avec un tag épinglé : changer la version
change le tag, donc l'image est absente, donc elle est tirée. Aucun besoin de
`pull: always`, qui interrogerait le registre à chaque passage pour rien.

⚠ NE PAS jouer ce playbook pendant qu'une CI tourne : il recrée les conteneurs
de runner et TUE les jobs en vol (journaux perdus). Vérifier `list_runs` avant —
et se rappeler qu'un merge est un déclencheur.

Refs arcodange-org/factory#50

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-30 08:54:43 +02:00
arcodange 1a8b6bf36c Merge pull request 'fix(ci_base_image) — le contexte de build doit être SUR la machine, pas sur le contrôleur' (#49) from arcodange/image-ci-build-distant into main 2026-07-29 23:51:17 +02:00
CI BotandClaude Opus 5 ba791ed055 fix(ci_base_image) — le contexte de build doit être SUR la machine, pas sur le contrôleur
Le playbook 03_cicd est mort sur les deux hôtes :

  "/Users/…/roles/ci_base_image/files/" is not an existing directory

`docker_image_build` s'exécute SUR LA CIBLE : son `path:` est un chemin de la
cible. Je passais `{{ role_path }}/files/`, un chemin du CONTRÔLEUR.

Le motif venait du rôle `playwright`, qui l'emploie LÉGITIMEMENT parce qu'il
construit en local. Recopié pour un build distant, il ne pouvait pas marcher —
et aucune relecture ne l'aurait montré, seule l'exécution le dit.

⚠ L'échec est arrivé AVANT les tâches qui déploient le runner : les deux
runners sont restés `Up 6 days`, rien n'a été cassé. Le seul effet fut un jeton
d'API Gitea créé par le rôle gitea_token, son comportement normal.

Le contexte est désormais déposé sur la machine (`/tmp/ci-base-image`), et la
RECONSTRUCTION DEVIENT CONDITIONNELLE : `never` en régime normal — le playbook
ne rebâtit pas 3,3 Go à chaque passage — mais `always` dès que le Dockerfile a
CHANGÉ sur la machine. Ajouter une bibliothèque devient donc effectif sans avoir
à penser à un drapeau.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 23:41:32 +02:00
arcodange 863ac68065 Merge pull request 'feat(ci): construire l'image des jobs CI lourds sur chaque machine à runner (+ implémente l'épinglage de l'ADR 20260407)' (#48) from arcodange/image-ci-runners into main 2026-07-29 23:33:29 +02:00
CI BotandClaude Opus 5 9f438c4968 fix(ci_base_image) — l'image livrait Node 18 : deux défauts que seule sa CONSTRUCTION a montrés
J'avais écrit dans cette PR « je n'ai pas pu construire l'image, sa base vit
derrière le certificat interne ». C'était une SUPPOSITION NON TESTÉE, et elle est
fausse : `docker pull gitea.arcodange.lab/…/runner-images:ubuntu-latest-ca` passe
sans rien configurer. En la construisant vraiment, deux défauts sont sortis — et
aucun n'était visible à la lecture du Dockerfile.

1. `runner-images:ubuntu-latest-ca` LIVRE NODE 18 (v18.20.8). Or `nuxi` importe
   `node:util.styleText`, absent de Node 18 : c'est la raison d'être du
   `container: node:20-bookworm` de la CI de kadans, que son CLAUDE.md interdit
   de retirer. Sans correctif, basculer la CI sur cette image cassait `nuxt build`
   sur un message parlant d'un import introuvable — jamais d'une version de Node.
   → Node 20 installé depuis NodeSource.

2. ET INSTALLER NE SUFFISAIT PAS. Après l'installation, `node --version` rendait
   TOUJOURS v18.20.8 : l'image de base précuit un node pour le toolcache d'act et
   le met EN TÊTE du PATH.

     which node → /opt/acttoolcache/node/18.20.8/arm64/bin/node
     /usr/bin/node --version → v20.20.2   ← le bon, mais il PERD

   → l'entrée 18 du toolcache est retirée ; la résolution retombe sur
     /usr/bin/node. ⚠ Conséquence assumée : `actions/setup-node` ne trouvera plus
     de Node 18 préinstallé — aucun workflow de kadans ne l'utilise, et l'image
     n'est servie qu'aux jobs qui DEMANDENT le label.

Le Dockerfile porte désormais une ASSERTION DE BUILD
(`node --version | grep -q "^v${NODE_MAJOR}\."`) : l'image ne peut plus se
construire si la résolution redevient mauvaise. Et le rôle vérifie la version au
déploiement (`failed_when`), au lieu de la supposer.

MESURES RÉELLES (construite en linux/arm64, l'architecture des runners) :

  TOTAL                              4,58 Go
  ├─ playwright install chromium     1,01 Go
  ├─ playwright install-deps          405 Mo
  ├─ Node 20 (NodeSource)             183 Mo
  └─ bun                              179 Mo

  Vérifié dans l'image : which node → /usr/bin/node v20.20.2 · bun 1.3.14 ·
  chromium-1228 + headless-shell + ffmpeg · /etc/ci-base.versions cohérent.

⚠ Ce que ces chiffres tranchent : le découpage en RUN séparés N'A PAS suffi à
rendre l'image poussable — 1,01 Go pour la plus grosse couche, soit ~4× les
261 Mo que le registre accepte (runner-images:ubuntu-latest-ca). Le build LOCAL
n'est donc pas une préférence, c'est la seule voie. Mesuré, plus supposé.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 23:09:14 +02:00
CI BotandClaude Opus 5 fdecabf8ca feat(ci): construire l'image des jobs CI lourds sur chaque machine à runner
Les jobs CI du dépôt kadans réinstallent, à CHAQUE exécution, des choses qui
changent tous les trimestres. Mesuré le 2026-07-29 sur le run 628 (runner
ARM64, 2 vCPU / 3 Gio) :

  npm install -g bun                        →    8,6 s
  playwright install --with-deps chromium   →  104,0 s   (apt-get, à chaque run)
                                               ────────
                                                112,6 s jetées par run, sur le
                                                job qui EST le chemin critique

Le cache `actions/cache` ne peut rien contre ces 104 s : il couvre le NAVIGATEUR
(299 Mo déjà mis en cache), pas ses dépendances SYSTÈME — `--with-deps` relance
apt quoi qu'il arrive.

POURQUOI CONSTRUIRE ICI PLUTÔT QUE POUSSER UNE IMAGE. La tentative de publier
l'image au registre a échoué (kadans#224 puis #225) : 3,81 Go dont une couche
unique de 1,36 Go, `docker push` casse en « connection reset by peer » — 7
couches passent, 3 sont retentées 50 fois puis abandonnées. À titre de
comparaison, runner-images:ubuntu-latest-ca (534,7 Mo, plus grosse couche
261 Mo) passe sans problème : la limite est entre 261 Mo et ~500 Mo par couche.
Construire localement supprime le problème — aucune couche ne traverse le
réseau.

Et c'est bien sur CHAQUE machine : avec `capacity: 1`, le parallélisme vient de
plusieurs Raspberry, et `container:` est résolu par le runner. Un job qui
atterrit là où l'image manque échoue AVANT sa première étape.

Trois choix de conception :

1. L'image hérite de runner-images:ubuntu-latest-ca, donc du certificat de la CA
   interne (step-ca). Repartir de node:20-bookworm obligerait à réinjecter le CA
   à la main, et tout job parlant à gitea.arcodange.lab échouerait en TLS.

2. TROIS `RUN` séparés (bun / dépendances système / navigateur), délibérément.
   La version qui a échoué faisait une couche de 1,36 Go. Ne pas les fusionner
   pour « gagner une couche ».

3. L'image est ÉPINGLÉE par un conteneur factice — ce qui implémente enfin la
   section 1 de docs/adr/20260407-docker-storage-gitea-runner.md, restée à
   l'état de proposition : system_docker.yml n'applique que le data-root sur
   disque externe et les log-opts. Sans épinglage, le ramasse-miettes de Docker
   supprime l'image dès que le disque se remplit — panne déjà constatée sur les
   images de runner elles-mêmes.

Le rôle vérifie sa sortie (`docker run … bun --version`) au lieu de supposer que
le build a suffi, et l'image écrit ses versions dans /etc/ci-base.versions pour
que la CI de kadans puisse les confronter à son bun.lock et échouer FORT sur une
dérive, plutôt que de la découvrir en « Executable doesn't exist ».

Nouveau label runner `ci-node-playwright`. `container.force_pull: false` est
déjà en place et devient REQUIS pour ce label : sans lui, act_runner tenterait
un pull d'une image qui n'est dans aucun registre.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-29 20:23:59 +02:00
arcodangeandClaude Opus 5 e84383ee34 feat(miroirs) — un dépôt personnel n'est pas une organisation
Le rôle gitea_repo ne savait viser qu'un propriétaire : l'organisation, des
deux côtés à la fois. Les dépôts qui vivent sous le compte personnel
`arcodange` ne pouvaient donc pas sortir du homelab — ni être balayés par
gitea_sync, qui n'interroge que /orgs/<org>/repos.

Trois séparations, toutes rétrocompatibles (les défauts reconduisent le
comportement org-vers-org des dix dépôts déjà en miroir) :

- le propriétaire côté Gitea (`gitea_repo_owner`) n'est plus le même objet que
  celui d'en face (`github_owner`, `gitlab_owner`) ;
- un compte personnel n'est pas une organisation : GitHub ne crée pas le dépôt
  au même endroit, d'où `github_owner_is_org` qui route vers POST /user/repos ;
- GitLab devient facultatif (`gitea_mirror_gitlab`). Il ne l'était pas : sa
  création attendait un 201 sans ignore_errors, si bien qu'un échec GitLab
  avortait l'itération — y compris la moitié GitHub, qui n'y était pour rien.

Deux défauts corrigés au passage, tous deux silencieux :

- les trois listages de gitea_sync ne paginaient pas (30 chez GitHub, 20 chez
  GitLab). Sous la taille d'une page tout va bien ; au-delà, la différence
  entre forges désigne de FAUX dépôts manquants et le rôle les « répare » ;
- la migration entrante posait `repo_owner: github_organization` pour désigner
  le propriétaire DANS Gitea.

Et un piège découvert en exécutant : un dépôt GitHub créé vide adopte comme
branche par défaut la PREMIÈRE branche que le miroir lui pousse — `kadans` a
atterri sur `arcodange/adr-ddd-front`. Le rôle réaligne désormais sur la
branche par défaut de Gitea ; le miroir étant asynchrone, l'alignement échoue
au run qui crée le dépôt et réussit au suivant, d'où le failed_when permissif.

Ce qui sort du homelab reste un CHOIX : playbooks/07_mirrors.yml parcourt une
liste explicite et relue (`gitea_mirrored_repos`) plutôt que la différence
automatique entre forges, qui recréerait un dépôt supprimé exprès.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-27 13:34:26 +02:00
arcodange 726456c5ed Merge pull request 'docs(adr) — stockage objet : le point « non vérifié » est tranché par le réel' (#46) from arcodange/adr-minio-listbucket into main 2026-07-26 11:44:30 +02:00
arcodangeandClaude Opus 5 e0cd93c6d3 docs(adr) — stockage objet : le point « non vérifié » est tranché par le réel
L'ADR annonçait que les noms d'actions MinIO de la politique du provisionneur
venaient de la documentation, pas d'un essai. Le premier apply (kadans,
2026-07-26) a répondu : tout le bloc admin passe, il manquait `s3:ListBucket`
côté S3 — le provider interroge l'existence du bucket avant de le créer.

La conséquence devient un constat, avec ce que ListBucket concède (la vue des
clés) et ce qu'il ne concède pas (leur contenu).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 11:43:47 +02:00
arcodange fc0dd854f1 Merge pull request 'docs(adr) — stockage objet MinIO : qui déclare quoi, et qui détient quoi' (#45) from arcodange/adr-stockage-objet into main 2026-07-26 10:46:34 +02:00
arcodangeandClaude Opus 5 aabedb0f3f docs(adr) — stockage objet MinIO : qui déclare quoi, et qui détient quoi
Trois questions indépendantes, tranchées lors du branchement de Kadans sur
MinIO (2026-07-26) : qui déclare les buckets d'une app, qui détient les
identifiants capables de les créer, et comment l'app lit les siens.

La décision de fond est du fondateur : CHACUN SON PÉRIMÈTRE. Une application
déclare ses buckets depuis son propre dépôt ; `tools` fournit le serveur, un
module de standardisation et un compte de provisionnement — pas la liste. Une
première version faisait tout porter par l'infra partagée : à ce rythme, chaque
bucket de chaque app devenait une PR sur le dépôt commun.

L'ADR consigne aussi les trois identités et leurs portées (root / provisionneur
/ compte de service), pourquoi la lecture des identifiants est une propriété
inconditionnelle de la plateforme plutôt qu'une déclaration par app, et pourquoi
les octets ne transitent pas par l'API — avec les conséquences que ça impose
(endpoint public, CORS aux origines exactes, pas de basic-auth sur l'ingress S3).

Les alternatives écartées sont listées avec leur motif, dont deux que j'avais
moi-même proposées et qui étaient plus faibles.

Deux limites assumées y figurent : le provisionneur est un secret PARTAGÉ entre
rôles CI (sa compromission permet de créer des buckets, pas de lire des objets),
et les noms d'actions d'administration MinIO n'ont pas été éprouvés contre le
serveur au moment d'écrire.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01CoafGWmRVESaWX819USUUA
2026-07-26 10:21:04 +02:00
arcodange b06b7e79ac Merge pull request 'fix(argocd): url-shortener enfin syncable — ignorer le volumeName épinglé de son PVC' (#44) from arcodange/url-shortener-pvc-sync into main 2026-07-25 10:49:41 +02:00
arcodangeandClaude Fable 5 73bf7d1170 fix(argocd): url-shortener enfin syncable — ignorer le volumeName épinglé de son PVC
L'app url-shortener était en SyncError permanent : son PVC live porte un
spec.volumeName épinglé (rebind du volume Longhorn après le drill coupure de
courant) absent du chart ; chaque sync tentait donc de le vider, refus API
(spec immuable après création), échec en boucle malgré automated+selfHeal.

- apps.yaml : passthrough générique ignoreDifferences + syncOptions par app.
- values.yaml : url-shortener ignore /spec/volumeName du PVC, avec
  RespectIgnoreDifferences=true pour que l'apply réinjecte la valeur live au
  lieu de la vider (le cas d'usage documenté d'ArgoCD pour les champs
  immuables).

Rendu helm vérifié : seule l'Application url-shortener change.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-25 10:47:34 +02:00
arcodange 1ed3154668 Merge pull request 'fix(dns): coredns-custom — importer les blocs *.server à la racine du Corefile (sinon CoreDNS crash)' (#40) from arcodange/coredns-custom-import into main
Reviewed-on: #40
2026-07-24 12:44:33 +02:00
arcodange a59049d436 Merge pull request 'docs(runbook) — corrige 09 : ne pas écrire vaultConnectionRef hors du ns tools' (#43) from arcodange/runbook-fix-connref into main 2026-07-24 11:29:41 +02:00
arcodangeandClaude Opus 4.8 51d01f47c2 docs(runbook) — corrige 09 : NE PAS écrire vaultConnectionRef dans un ns applicatif
Le chapitre « service compagnon » montrait `vaultConnectionRef: default` dans
l'exemple VaultAuth — c'est faux hors du namespace `tools` et ça a réellement bloqué
le déploiement de kadans-api (pods en CreateContainerConfigError, VaultDynamicSecret
sur « VaultConnection default not found »).

VSO résout vaultConnectionRef dans le namespace DU CR ; la VaultConnection `default`
ne vit que dans `tools`. Les apps hors `tools` (erp, webapp) OMETTENT le champ et
laissent VSO retomber sur sa defaultVaultConnection. On retire donc la ligne de
l'exemple + on ajoute un encart WARNING dédié au piège.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ws8L74dVZmp97Wu36fm8j
2026-07-24 11:29:17 +02:00
arcodange 97b2f49d49 Merge pull request 'docs(runbook) — chapitre « service compagnon » (namespace + stack partagés)' (#42) from arcodange/runbook-service-compagnon into main 2026-07-24 10:40:13 +02:00
arcodange cb83c03d15 Merge pull request 'feat(argocd) — enregistre kadans-api (app-of-apps, namespace kadans)' (#41) from arcodange/register-kadans-api into main 2026-07-24 10:39:54 +02:00
arcodangeandClaude Opus 4.8 e1167eec27 docs(runbook) — chapitre « service compagnon » (namespace + stack partagés)
Le runbook new-web-app couvre l'app autonome (dépôt/base/Vault/namespace propres,
tout nommé <app>). Il manquait le cas du SERVICE COMPAGNON : un second service qui
partage le namespace — et parfois le stack Vault/DB — d'une app existante (API cœur
à côté de son front, façade d'analyse). Deux précédents vivants non documentés :
kadans-jobs (namespace seul) et kadans-api (namespace + base + Vault).

- Nouvelle page 09-service-compagnon.md : compagnon vs app autonome ; les deux
  formes (sans état / partage Vault+DB) ; le PIÈGE du VaultAuth manquant quand
  l'app primaire ne consomme pas Vault (front statique) → le compagnon pose son
  propre VaultAuth mais avec le rôle+SA du PRIMAIRE ; carte, précédents, delta de
  checklist.
- 07-argocd-register.md : ajoute la ligne `namespace:` aux options (elle existait
  dans values.yaml — kadans-jobs — mais n'était pas documentée) ; corrige le
  callout qui affirmait le namespace « non configurable ».
- conventions.md : note l'exception compagnon à la règle « tout est <app> ».
- README.md : entrée 09 dans l'index + Last Updated.

Vérifié : VaultAuth erp nommé `auth` ; connexion via pgbouncer.tools ; liens
internes tous résolus.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ws8L74dVZmp97Wu36fm8j
2026-07-24 10:38:27 +02:00
arcodangeandClaude Opus 4.8 1365c95c2f feat(argocd) — enregistre kadans-api (app-of-apps, namespace kadans)
Ajoute l'API cœur au registre gitea_applications. ArgoCD crée une Application
`kadans-api` (source arcodange/kadans-api, path chart, targetRevision HEAD), sync
automatique prune+selfHeal, image-updater par digest sur :latest — même moule que
les autres apps.

Namespace `kadans` (comme kadans-jobs) : kadans-api partage le stack Vault/DB déjà
en place pour l'app front (VaultAuth `kadans`, rôle Postgres dynamique
postgres/creds/kadans, ServiceAccount `kadans`, policy KV `kadans`). Aucun nouvel
iac/DB/Vault à provisionner.

À merger APRÈS le fix chart kadans-api (VaultAuth + hôte DB pgbouncer.tools) pour
que la première synchro ArgoCD parte d'un chart correct.

helm template rend l'Application kadans-api → repoURL arcodange/kadans-api,
namespace kadans, CreateNamespace, digest.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ws8L74dVZmp97Wu36fm8j
2026-07-24 10:28:28 +02:00
arcodangeandClaude Fable 5 ec49706952 fix(dns): import coredns custom *.server blocks at Corefile root — inside .:53 it crashes CoreDNS
The never-yet-applied k3s_dns.yml placed 'import /etc/coredns/custom/*.server'
INSIDE the .:53 server block. *.server files hold full server blocks
(arcodange.lab:53 {…}), which only parse at Corefile root — inside a block
CoreDNS dies at startup with "Unknown directive 'arcodange.lab:53'"
(CrashLoopBackOff, cluster DNS fully down; lived it on 2026-07-24 while
restoring the expired *.arcodange.lab certificate).

Also restores the stock 'loadbalance' plugin dropped by the playbook.

Context: cluster CoreDNS forwarded to the node's resolv.conf, which lists the
ISP box's IPv6 RDNSS next to the Pi-holes — NXDOMAIN roulette for *.lab names.
That's what left step-issuer unable to reach ssl-ca.arcodange.lab:8443 and let
the 24h wildcard cert expire this morning. The (fixed) playbook pins .lab
resolution to the Pi-holes via the coredns-custom ConfigMap; applied live on
2026-07-24, wildcard renewed, strict TLS verified on gitea/argocd/grafana.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-24 10:13:41 +02:00
arcodange 0612da184c Merge pull request 'fix(cicd): cap act_runner jobs (3g/2cpu) and capacity 2→1 — a build can no longer take down pi1' (#39) from arcodange/runner-limits into main
Reviewed-on: #39
2026-07-24 09:51:08 +02:00
29 changed files with 1135 additions and 55 deletions
@@ -9,3 +9,80 @@
# so the secret propagation playbook iterates over this list.
gitea_secret_propagation_users:
- arcodange
# Dépôts mis en miroir vers GitHub (et GitLab) par playbooks/06_mirrors.yml.
# Gitea reste la source ; les forges publiques ne reçoivent qu'une copie poussée.
#
# `gitea_sync` ne balaie qu'UN propriétaire à la fois et déduit les manques en
# comparant les forges : utile pour l'organisation, inadapté ici, où l'on choisit
# dépôt par dépôt ce qui sort du homelab. D'où cette liste, explicite et relue.
#
# owner : propriétaire côté Gitea
# github_owner : propriétaire côté GitHub (un compte personnel n'est pas une
# organisation — voir github_owner_is_org)
# gitlab_namespace : ID numérique du groupe ou du compte GitLab d'accueil
gitea_mirrored_repos:
- name: kadans
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
description: application d'entrainement social de danse
- name: kadans-api
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
- name: kadans-dossier
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
- name: kadans-jobs
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
- name: video_analysis
owner: arcodange
github_owner: arcodange
github_owner_is_org: false
# Espace GitLab qui accueille les dépôts du compte personnel. À renseigner avec
# l'ID du namespace « arcodange » sur gitlab.com (Settings → General) : sans lui,
# la création GitLab retomberait dans le groupe arcodange-org.
gitlab_personal_namespace_id: ~
# ══════════════════════════════════════════════════════════════════════════
# VERSION DU RUNNER GITEA ACTIONS — ÉPINGLÉE, ET C'EST LE POINT.
#
# Le playbook 03_cicd déployait `gitea/act_runner:latest` avec `pull: missing`,
# c'est-à-dire la pire combinaison possible : un tag FLOTTANT qui n'est JAMAIS
# rafraîchi. Chaque hôte garde ce que « latest » voulait dire le jour de son
# premier pull — d'où deux machines censées être équivalentes qui divergent
# (constaté le 2026-07-30) :
#
# pi1 : sha256:7bdc8d31… → act_runner v0.3.1
# pi3 : sha256:0f65fa10… → act_runner v0.2.13
#
# Effet mesuré : le MÊME job, sur la MÊME image de CI, met 511 s sur pi1 et
# 397 s sur pi3 — 114 s d'écart imputables à la machine. Et pi3 (v0.2.13) a mal
# lu la définition d'un job dont il dépendait (« 'runs-on' key not defined »,
# puis « No steps found »).
#
# ⚠ NE PAS remplacer par `latest` + `pull: always` : `latest` vaut aujourd'hui
# 0.6.1, soit 3 à 4 versions mineures devant tout ce qui est éprouvé ici. Le
# runner exécute TOUTE la CI de la forge — une montée subie, non datée et non
# choisie, s'y paie cher. Une version épinglée se relit, se date et se recule.
# ⚠ L'IMAGE A CHANGÉ DE NOM. `gitea/act_runner` est gelée à 0.6.1 ; le
# successeur officiel est `gitea/runner`, et son binaire s'appelle désormais
# `gitea-runner` (plus `act_runner`).
# Vérifié avant de basculer — c'est un REMPLACEMENT DIRECT pour ce compose :
# • entrypoint identique : /sbin/tini -- run.sh
# • mêmes variables lues : CONFIG_FILE, GITEA_INSTANCE_URL,
# GITEA_RUNNER_{REGISTRATION_TOKEN,NAME,LABELS}
# • config.yaml compatible : capacity, labels, cache.*, container.force_pull,
# options, valid_volumes, host.workdir_parent — AUCUNE clé utilisée ici n'a
# disparu (comparé à `gitea-runner generate-config` de la 2.3.0).
# Le blog de Gitea 1.27 recommande « Gitea Runner 2.0.0 » ; 2.3.0 est la même
# lignée majeure, en plus récent. Gitea reste par ailleurs compatible fil-à-fil
# avec les runners plus anciens — il désactive simplement les fonctionnalités
# qu'ils n'annoncent pas.
gitea_runner_image: "gitea/runner"
gitea_runner_version: "2.3.0"
@@ -1,4 +1,13 @@
gitea_version: 1.25.5
# ⚠ Montée 1.25.5 → 1.27.1 : DEUX versions mineures, avec migrations de base
# IRRÉVERSIBLES (Gitea ne sait pas redescendre après migration). Sauvegardes du
# jour vérifiées avant la bascule (pg_dumpall 13 Mo intègre + archive fichiers
# 1,7 Go intègre, /mnt/backups).
# Changements cassants relevés dans les notes de version, et leur portée ICI :
# • workflows réutilisables externes retirés → AUCUN dans nos trois dépôts (vérifié)
# • nonce CSP exigé pour les scripts inline → concerne les templates
# personnalisés ; nous n'en avons pas
# • X-Content-Type-Options: nosniff par défaut
gitea_version: 1.27.1
gitea_database:
db_name: gitea
@@ -51,3 +60,13 @@ gitea:
- /home/pi/arcodange/docker_composes/gitea/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# Gitea tourne sur pi2 hors k3s (docker compose nu) : invisible du
# scheduler k8s et sans plafond jusqu'ici (`docker inspect` mesurait
# NanoCPUs=0, Memory=0). Mesuré à 12 % CPU / 417 Mi au repos — la
# limite est un filet pour les pics (gros push, opérations git
# lourdes), pas un dimensionnement pour la charge normale.
deploy:
resources:
limits:
cpus: "1.5"
memory: 1536M
@@ -19,7 +19,57 @@ postgres:
- "5432:5432"
volumes:
- /home/pi/arcodange/docker_composes/postgres/data:/var/lib/postgresql/data
# Postgres tourne sur pi2 hors k3s (docker compose nu) : invisible du
# scheduler k8s et sans plafond jusqu'ici (`docker inspect` mesurait
# NanoCPUs=0, Memory=0). Mesuré à 3-5 % CPU au repos — la limite est un
# filet, pas un dimensionnement pour la charge normale.
deploy:
resources:
limits:
cpus: "1"
memory: 1024M
pgbouncer:
auth_user: &pgbouncer_auth pgbouncer_auth
auth_user_password: *pgbouncer_auth
# PostGIS — spatial types for the databases that ask for it.
#
# Kadans stores neighbourhood ("zone") outlines as real geometry, so it needs
# geometry(MultiPolygon,4326), ST_Contains and a GiST index. The image stays
# `postgres:16.3-alpine`: the extension is installed INTO the running container
# by setup/postgres.yml, exactly like the pgbouncer role and the app databases
# are created there. No custom image (founder's call, 2026-08-08).
#
# WARNING — this is a per-CONTAINER install, not a per-VOLUME one. `apk add`
# writes to the container's writable layer, so recreating the container (image
# change, `docker compose up --force-recreate`) REMOVES PostGIS while the data
# keeps its geometry columns — every spatial query then fails until this
# playbook runs again. The install task is therefore idempotent and runs after
# every compose deploy, and it is the reason `postgis_verifier` exists below:
# a silent absence would look like an application bug.
postgis:
# Only these databases get the extension. Adding one here is the whole change.
databases:
- kadans
# The Alpine package. Pinned to a MAJOR line, not a patch: postgis 3.x
# upgrades within a major are ABI-compatible with a given PostgreSQL major.
paquet: postgis
# ⚠ WHY THE COPY STEP EXISTS — measured on pi2 (arm64), 2026-08-08.
# `apk add postgis` alone SUCCEEDS and `CREATE EXTENSION postgis` still fails:
#
# ERROR: extension "postgis" is not available
# DETAIL: Could not open extension control file
# "/usr/local/share/postgresql/extension/postgis.control"
#
# Alpine's package targets Alpine's own PostgreSQL layout
# (/usr/share/postgresql16, /usr/lib/postgresql16), while the official
# `postgres:16-alpine` image builds the server into /usr/local. The files are
# on disk, the server looks elsewhere. Relocating them makes it work — proven
# in a throwaway container: PostGIS 3.4 USE_GEOS=1 USE_PROJ=1, and a Lyon
# polygon round-tripping through ST_GeomFromText/ST_AsGeoJSON.
#
# Do NOT "simplify" this to a bare `apk add`. An `apk add --simulate` reports
# OK, the install reports OK, and the extension is still unusable.
source_partagee: /usr/share/postgresql16/extension
source_lib: /usr/lib/postgresql16
@@ -8,6 +8,13 @@ raspberries:
ansible_host: pi2.home
preferred_ip: 192.168.1.202
ansible_ssh_extra_args: '-o StrictHostKeyChecking=no'
# Gitea + Postgres tournent ici en docker compose nu, hors k3s (cf.
# inventory/group_vars/gitea|postgres) : invisibles du scheduler k8s,
# qui croyait donc disposer des 4 cœurs / 7,6 Gi en entier. Réservé
# informatif seulement (pas d'--enforce-node-allocatable ajouté) : ça
# réduit l'Allocatable annoncé par le kubelet, pas d'éviction ajoutée.
kubelet_reserved_args: >-
--kubelet-arg=system-reserved=cpu=2,memory=2Gi
pi3:
ansible_host: pi3.home
preferred_ip: 192.168.1.203
@@ -5,6 +5,13 @@
roles:
- arcodange.factory.gitea_token # generate gitea_api_token used to replace generated token with set name if required
# Image de base des jobs CI lourds (Node + Bun + Chromium), construite ICI,
# sur chaque machine à runner, puis épinglée contre le ramasse-miettes Docker.
# Le même groupe d'hôtes que le runner, et ce n'est pas un détail : avec
# `capacity: 1` (ci-dessous), le parallélisme vient de PLUSIEURS machines, et
# `container:` est résolu par le runner — un job qui atterrit là où l'image
# manque échoue AVANT sa première étape.
- arcodange.factory.ci_base_image
tasks:
@@ -23,7 +30,14 @@
name: arcodange_factory_gitea_action
services:
gitea_action:
image: gitea/act_runner:latest
# ⚠ VERSION ÉPINGLÉE (inventory/group_vars/all/gitea.yml), PAS `latest`.
# `latest` + `pull: missing` = tag flottant JAMAIS rafraîchi : chaque
# hôte gardait ce que « latest » voulait dire à son premier pull, d'où
# pi1 en v0.3.1 et pi3 en v0.2.13 sur des machines censées être
# équivalentes (114 s d'écart mesurés sur le même job).
# Avec un tag épinglé, `pull: missing` redevient CORRECT : changer la
# version change le tag, donc l'image est absente, donc elle est tirée.
image: "{{ gitea_runner_image }}:{{ gitea_runner_version }}"
container_name: gitea_action
restart: always
environment:
@@ -32,7 +46,7 @@
http://{{ hostvars[groups.gitea[0]].ansible_host }}:3000
GITEA_RUNNER_REGISTRATION_TOKEN: "{{ gitea_runner_token_cmd.stdout }}"
GITEA_RUNNER_NAME: arcodange_global_runner_{{ inventory_hostname }}
GITEA_RUNNER_LABELS: ubuntu-latest:docker://gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca,ubuntu-latest-ca:docker://gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca
GITEA_RUNNER_LABELS: ubuntu-latest:docker://gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca,ubuntu-latest-ca:docker://gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca,ci-node-playwright:docker://ci-node-playwright:latest
ports:
- "43707:43707"
networks:
@@ -91,6 +105,15 @@
labels:
- "ubuntu-latest:docker://gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca"
- "ubuntu-latest-ca:docker://gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca"
# Jobs CI lourds (Node + Bun + Chromium préinstallés) —
# image construite LOCALEMENT par le rôle ci_base_image, sur
# cette machine. Elle n'est volontairement PAS dans le
# registre : 3,81 Go dont une couche de 1,36 Go, dont le
# push casse en « connection reset by peer » (mesuré
# 2026-07-29, kadans#225). `force_pull: false` ci-dessous
# est donc REQUIS pour ce label — sans lui, act_runner
# tenterait un pull et échouerait.
- "ci-node-playwright:docker://ci-node-playwright:latest"
cache:
# Enable cache server to use actions/cache.
@@ -0,0 +1,60 @@
---
# Miroirs push Gitea → GitHub (et GitLab).
#
# Gitea est la source ; chaque dépôt listé dans `gitea_mirrored_repos`
# (inventory/group_vars/all/gitea.yml) reçoit un miroir push rafraîchi toutes les
# 8 h ET à chaque commit. Les dépôts créés en face le sont en PRIVÉ.
#
# uv run ansible-playbook -i ansible/arcodange/factory/inventory \
# ansible/arcodange/factory/playbooks/06_mirrors.yml
#
# GitHub seulement (tant que l'espace GitLab personnel n'est pas renseigné) :
# … -e gitea_mirror_gitlab=false
#
# Le jeton Gitea est frappé pour la durée du run puis révoqué en post_tasks.
- name: Mettre les dépôts Gitea en miroir sur GitHub et GitLab
hosts: localhost
gather_facts: true # gitea_token date son jeton avec ansible_date_time
roles:
- role: arcodange.factory.gitea_token
tags:
- gitea_mirrors
tasks:
- name: Poser le miroir de chaque dépôt déclaré
tags: gitea_mirrors
include_role:
name: arcodange.factory.gitea_repo
apply:
tags: gitea_mirrors
vars:
gitea_repo_name: "{{ mirrored_repo.name }}"
gitea_repo_owner: "{{ mirrored_repo.owner }}"
gitea_repo_description: "{{ mirrored_repo.description | default('') }}"
github_owner: "{{ mirrored_repo.github_owner | default(mirrored_repo.owner) }}"
github_owner_is_org: "{{ mirrored_repo.github_owner_is_org | default(true) }}"
gitlab_owner: "{{ mirrored_repo.gitlab_owner | default(mirrored_repo.owner) }}"
gitlab_namespace_id: >-
{{ mirrored_repo.gitlab_namespace
| default(gitlab_personal_namespace_id)
| default(89826881, true) }}
# Ce qui sort du homelab reste privé en face.
github_repo_private: true
gitlab_repo_visibility: private
loop: "{{ gitea_mirrored_repos }}"
loop_control:
loop_var: mirrored_repo
label: "{{ mirrored_repo.owner }}/{{ mirrored_repo.name }}"
post_tasks:
- name: Révoquer le jeton Gitea du run
tags:
- gitea_mirrors
include_role:
name: arcodange.factory.gitea_token
apply:
tags: gitea_mirrors
vars:
gitea_token_delete: true
@@ -25,6 +25,63 @@
applications_databases:
gitea: "{{ gitea_database }}"
# ── PostGIS ────────────────────────────────────────────────────────────
# Installed INTO the running container rather than baked into a custom
# image (founder's call, 2026-08-08). See group_vars/postgres/postgres.yml
# for why the copy step is not optional, and for the durability caveat.
#
# Runs after the compose deploy above ON PURPOSE: if that task recreated
# the container, the writable layer is fresh and PostGIS is gone with it.
# This is what makes the pair (deploy, install) safe to replay.
- name: Install PostGIS into the Postgres container
ansible.builtin.shell: |
set -eu
docker exec {{ postgres_container_name }} sh -c '
set -eu
apk add --no-cache {{ postgis.paquet }} >/dev/null
cp -r {{ postgis.source_partagee }}/* "$(pg_config --sharedir)/extension/"
cp -r {{ postgis.source_lib }}/*.so "$(pg_config --pkglibdir)/"
'
# `apk add` is idempotent and the copies overwrite identical files, so a
# replay changes nothing observable. We do not pretend to detect that:
# claiming `changed_when: false` outright would hide a REAL first install.
register: postgis_installation
changed_when: "'Installing' in postgis_installation.stdout"
- name: Enable PostGIS on the databases that need it
ansible.builtin.shell: |
docker exec {{ postgres_container_name }} \
psql -U postgres -d {{ item }} -tAc 'CREATE EXTENSION IF NOT EXISTS postgis;'
loop: "{{ postgis.databases }}"
register: postgis_activation
changed_when: "'CREATE EXTENSION' in postgis_activation.stdout"
# ⚠ THE HALF THAT MATTERS. Without it, a botched install leaves a green
# playbook and an application that fails at its first spatial query — the
# symptom would land in Kadans, days later, looking like an app bug.
# We ask the database itself, and we FAIL on anything unexpected.
- name: Verify PostGIS answers on every database
ansible.builtin.shell: |
docker exec {{ postgres_container_name }} psql -U postgres -d {{ item }} -tAc \
"SELECT postgis_version() || ' | ' || ST_AsGeoJSON(ST_SetSRID(ST_Point(4.83, 45.76), 4326));"
loop: "{{ postgis.databases }}"
register: postgis_verifier
changed_when: false
# Not just "the command exited 0": a real geometry must come back with
# the SRID applied. A stub that answered an empty string would pass a
# bare rc check — and prove nothing.
failed_when: >-
postgis_verifier.rc != 0
or 'USE_GEOS=1' not in postgis_verifier.stdout
or '"type":"Point"' not in postgis_verifier.stdout
- name: Report the PostGIS version in use
ansible.builtin.debug:
msg: "PostGIS on {{ item.item }} → {{ item.stdout | trim }}"
loop: "{{ postgis_verifier.results }}"
loop_control:
label: "{{ item.item }}"
- name: Create auth_user for pgbouncer (connection pool component)
ansible.builtin.shell: |
docker exec -it {{ postgres_container_name }} psql -U postgres -d {{ database }} -tc "{{ pg_instruction.replace('$','\$') }}"
@@ -54,7 +54,11 @@
cache 30
loop
reload
loadbalance
import /etc/coredns/custom/*.override
import /etc/coredns/custom/*.server
forward . {{ pihole_ips | map('regex_replace', '^(.*)$', '\1:53') | join(' ') }}
}
# Les fichiers *.server contiennent des BLOCS SERVEUR complets (ex: `arcodange.lab:53 {…}`) :
# leur import doit vivre au niveau racine du Corefile. À l'intérieur de `.:53 {}`,
# CoreDNS crashe au parse (« Unknown directive 'arcodange.lab:53' ») — vécu le 2026-07-24.
import /etc/coredns/custom/*.server
@@ -34,14 +34,23 @@
# ansible.builtin.import_playbook: k3s.orchestration.reset
vars:
k3s_version: v1.34.3+k3s1
# ⚠ PAS de guillemets autour de key=value : `--kubelet-arg="k=v"` (avec
# guillemets) fait ressortir un `\=` littéral dans l'ExecStart généré par
# k3s-install.sh — kubelet refuse ensuite de démarrer ("unknown flag:
# --container-log-max-files\"), boucle de redémarrage jusqu'à NotReady.
# Mesuré le 11/08 sur pi2 : latent depuis des mois (le service n'avait pas
# redémarré depuis avril, donc jamais régénéré par la version actuelle du
# script), révélé par le premier restart forcé par ce playbook. La forme
# SANS guillemets (`--kubelet-arg=k=v`) traverse la génération intacte.
extra_server_args: >-
--docker --disable traefik
--kubelet-arg="container-log-max-files=5"
--kubelet-arg="container-log-max-size=10Mi"
--kubelet-arg=container-log-max-files=5
--kubelet-arg=container-log-max-size=10Mi
extra_agent_args: >-
--docker
--kubelet-arg="container-log-max-files=5"
--kubelet-arg="container-log-max-size=10Mi"
--kubelet-arg=container-log-max-files=5
--kubelet-arg=container-log-max-size=10Mi
{{ kubelet_reserved_args | default('') }}
api_endpoint: "{{ hostvars[groups['server'][0]]['ansible_host'] | default(groups['server'][0]) }}"
- name: how to reach k3s
@@ -0,0 +1,62 @@
---
# Image de base des jobs CI lourds (Node + Bun + Chromium), construite SUR CHAQUE
# machine qui héberge un runner Gitea.
#
# POURQUOI CONSTRUIRE PLUTÔT QUE POUSSER (mesuré le 2026-07-29, kadans#224/#225) :
# une image Node + Playwright + Chromium pèse 3,81 Go, avec une couche unique de
# 1,36 Go. Son `docker push` vers gitea.arcodange.lab casse en
# « connection reset by peer » : 7 couches passent, 3 sont réinitialisées et
# retentées 50 fois avant abandon. À titre de comparaison,
# `runner-images:ubuntu-latest-ca` (534,7 Mo, plus grosse couche 261 Mo) passe
# sans problème — la limite est donc entre 261 Mo et ~500 Mo par couche.
#
# Construire localement supprime le problème : aucune couche ne traverse le
# réseau. Et comme `capacity: 1` par runner (03_cicd.yml), le parallélisme vient
# de PLUSIEURS machines — l'image doit donc exister sur CHACUNE d'elles, ce que
# ce rôle garantit.
# On hérite de l'image de runner maison : elle porte DÉJÀ le certificat de la CA
# interne (step-ca). Repartir de `node:20-bookworm` obligerait à réinjecter le CA
# à la main, et un job qui parle à gitea.arcodange.lab échouerait en TLS.
ci_base_image_from: gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca
ci_base_image_name: ci-node-playwright
ci_base_image_tag: latest
# ⚠ Ces versions doivent suivre le `bun.lock` du dépôt kadans. Le dépôt s'en
# protège : l'image écrit ce qu'elle a cuit dans /etc/ci-base.versions, et la CI
# de kadans CONFRONTE ce fichier à son lockfile pour échouer FORT plutôt que de
# dériver en silence (des navigateurs qui ne correspondent plus au client
# Playwright donnent « Executable doesn't exist », loin de la cause).
ci_base_image_bun_version: '1.3.14'
ci_base_image_playwright_version: '1.61.1'
# ⚠ NODE 20 EST OBLIGATOIRE, ET CE N'EST PAS UN CONFORT.
# `runner-images:ubuntu-latest-ca` livre Node **18** (v18.20.8, constaté en
# lançant l'image). Or `nuxi` importe `node:util.styleText`, absent de Node 18 :
# c'est la raison d'être du `container: node:20-bookworm` que portait la CI de
# kadans, et que son CLAUDE.md interdit de retirer. Sans cette surcharge, tout
# job Nuxt basculé sur cette image casse au premier build, sur un message qui
# parle d'un import introuvable et jamais d'une version de Node.
ci_base_image_node_major: 20
# Épinglage par conteneur factice — remède décrit par
# docs/adr/20260407-docker-storage-gitea-runner.md §1, jusqu'ici resté à l'état
# de proposition (system_docker.yml n'applique que le data-root et les log-opts).
# Sans lui, le ramasse-miettes de Docker supprime l'image dès que le disque se
# remplit, et la CI casse sur une image manquante — panne déjà constatée sur les
# images de runner elles-mêmes.
ci_base_image_pin: true
# Où le contexte de build est déposé SUR LA MACHINE CIBLE. `docker_image_build`
# s'exécute sur la cible : son `path:` est un chemin de la cible, jamais du
# contrôleur. (Première version : `{{ role_path }}/files/` → le playbook mourait
# sur « is not an existing directory », sur les deux hôtes.)
ci_base_image_contexte: /tmp/ci-base-image
# Reconstruire même si l'image existe déjà. ⚠ Inutile pour un simple changement
# du Dockerfile : le rôle le détecte et reconstruit tout seul (voir tasks/).
# Ce drapeau sert aux cas que le Dockerfile ne montre pas — une montée de
# `bun.lock` côté kadans, par exemple, qui change les VERSIONS attendues sans
# changer le fichier.
ci_base_image_force_rebuild: false
@@ -0,0 +1,101 @@
# Image de base des jobs CI lourds — construite SUR CHAQUE machine à runner.
#
# Elle cuit une fois pour toutes ce que chaque exécution de CI réinstallait.
# Mesures du dépôt kadans (run 628, 2026-07-29, runner ARM64 2 vCPU / 3 Gio) :
#
# npm install -g bun → 8,6 s par run
# playwright install --with-deps chromium → 104,0 s par run (apt-get)
# ────────
# 112,6 s jetées à CHAQUE run,
# sur le job du chemin critique
#
# Le cache `actions/cache` ne peut rien contre ces 104 s : il couvre le
# NAVIGATEUR (299 Mo déjà mis en cache), pas ses dépendances SYSTÈME —
# `--with-deps` relance `apt-get` quoi qu'il arrive.
#
# ⚠ ON HÉRITE DE L'IMAGE DE RUNNER MAISON, ET C'EST ESSENTIEL : elle porte le
# certificat de la CA interne (step-ca). Une image repartant de `node:20-bookworm`
# ne ferait pas confiance à gitea.arcodange.lab, et tout job qui lui parle
# échouerait en TLS.
ARG CI_BASE_FROM=gitea.arcodange.lab/arcodange-org/runner-images:ubuntu-latest-ca
FROM ${CI_BASE_FROM}
ARG BUN_VERSION=1.3.14
ARG PLAYWRIGHT_VERSION=1.61.1
ENV DEBIAN_FRONTEND=noninteractive
# Chemin des navigateurs, figé et hors du HOME : un job qui tourne sous un autre
# utilisateur doit les retrouver.
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# ══════════════════════════════════════════════════════════════════════════
# ⚠ TROIS `RUN` SÉPARÉS, ET C'EST LE POINT DE CONCEPTION DE CE FICHIER.
#
# La première version faisait `npm i -g bun` puis `playwright install --with-deps`
# en deux couches, dont une de 1,36 Go — irrecevable par le registre. Ici, même
# si l'on décidait un jour de pousser cette image, chaque couche reste du même
# ordre de grandeur que celles qui passent déjà (261 Mo pour la plus grosse de
# `runner-images:ubuntu-latest-ca`).
#
# Ne pas fusionner ces `RUN` pour « gagner une couche » : le gain serait nul et
# la couche redeviendrait impossible à transporter.
# ══════════════════════════════════════════════════════════════════════════
# 0. NODE 20, ET C'EST OBLIGATOIRE — pas une préférence.
#
# ⚠ `runner-images:ubuntu-latest-ca` livre **Node 18** (v18.20.8, vérifié en le
# lançant). Or `nuxi` importe `node:util.styleText`, ABSENT de Node 18 : c'est la
# raison d'être du `container: node:20-bookworm` que la CI de kadans portait, et
# que son `CLAUDE.md` interdit explicitement de retirer.
#
# Sans cette couche, une CI qui bascule sur cette image casse au premier `nuxt
# build` — et le message parle d'un import introuvable, pas d'une version de Node.
# Le défaut a été trouvé en CONSTRUISANT l'image puis en lançant `node --version`
# dedans ; aucune lecture du Dockerfile ne l'aurait montré.
# ⚠⚠ ET INSTALLER NE SUFFIT PAS — il faut aussi que `node` RÉSOLVE vers le bon.
# Constaté en lançant l'image : après l'installation de Node 20 par apt,
# `node --version` rendait toujours **v18.20.8**, parce que l'image de base
# précuit un node pour le toolcache d'act et le met EN TÊTE du PATH :
#
# which node → /opt/acttoolcache/node/18.20.8/arm64/bin/node
# PATH → /opt/acttoolcache/node/18.20.8/arm64/bin:/usr/local/sbin:/usr/bin:…
# /usr/bin/node --version → v20.20.2 ← le bon, mais il PERD
#
# On retire donc l'entrée 18 du toolcache : le segment de PATH devient inexistant
# (inoffensif) et la résolution retombe sur /usr/bin/node, en 20.
# ⚠ Conséquence assumée : `actions/setup-node` ne trouvera plus de Node 18
# préinstallé dans cette image. Aucun workflow de kadans ne l'utilise, et l'image
# n'est servie qu'aux jobs qui DEMANDENT le label `ci-node-playwright`.
ARG NODE_MAJOR=20
RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" -o /tmp/nodesource.sh \
&& bash /tmp/nodesource.sh \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -f /tmp/nodesource.sh \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /opt/acttoolcache/node \
&& echo "node résolu : $(which node) $(node --version)" \
&& node --version | grep -q "^v${NODE_MAJOR}\."
# 1. Bun (~180 Mo) — installé APRÈS Node 20, pour que son npm global soit celui
# de Node 20 et non celui de Node 18.
RUN npm install -g "bun@${BUN_VERSION}" \
&& npm cache clean --force
# 2. Les dépendances SYSTÈME de Chromium — c'est CETTE couche qui rachète les
# 104 s d'apt-get de chaque run.
RUN npx --yes "playwright@${PLAYWRIGHT_VERSION}" install-deps chromium \
&& rm -rf /var/lib/apt/lists/*
# 3. Le navigateur lui-même, séparé de ses dépendances système : les deux ne
# bougent pas au même rythme, et Docker ne réinvalide alors que la bonne.
RUN npx --yes "playwright@${PLAYWRIGHT_VERSION}" install chromium
# La trace opposable de ce qui est réellement cuit ici. La CI de kadans la LIT et
# la confronte à son `bun.lock` : une dérive de version doit échouer FORT, avec sa
# cause, plutôt que de se manifester par un « Executable doesn't exist » à
# vingt minutes de là.
RUN printf 'node=%s\nbun=%s\nplaywright=%s\n' \
"$(node --version)" "$(bun --version)" "${PLAYWRIGHT_VERSION}" \
> /etc/ci-base.versions \
&& cat /etc/ci-base.versions
@@ -0,0 +1,93 @@
---
# Construit l'image de base des jobs CI sur la machine courante, puis l'épingle.
#
# À exécuter sur les MÊMES hôtes que le runner Gitea (03_cicd.yml) : comme
# `capacity: 1`, le parallélisme vient de plusieurs machines, et un job qui
# atterrit sur une machine sans l'image échouerait AVANT sa première étape —
# `runs-on`/`container:` est résolu par le runner, pas par le workflow.
# ══════════════════════════════════════════════════════════════════════════
# ⚠ LE CONTEXTE DE BUILD DOIT ÊTRE SUR LA MACHINE, PAS SUR LE CONTRÔLEUR.
#
# `docker_image_build` s'exécute SUR LA CIBLE : son `path:` est un chemin de la
# cible. La première version passait `{{ role_path }}/files/` — un chemin du
# CONTRÔLEUR — et le playbook mourait sur les deux hôtes :
#
# "/Users/…/roles/ci_base_image/files/" is not an existing directory
#
# Le motif venait du rôle `playwright`, qui l'utilise LÉGITIMEMENT parce qu'il
# construit en local ; recopié tel quel pour un build distant, il ne peut pas
# marcher. On copie donc le contexte d'abord.
# ══════════════════════════════════════════════════════════════════════════
- name: Créer le répertoire de contexte de build sur la machine
ansible.builtin.file:
path: '{{ ci_base_image_contexte }}'
state: directory
mode: '0755'
- name: Déposer le Dockerfile sur la machine
ansible.builtin.copy:
src: Dockerfile
dest: '{{ ci_base_image_contexte }}/Dockerfile'
mode: '0644'
register: ci_base_image_dockerfile
- name: Construire {{ ci_base_image_name }}:{{ ci_base_image_tag }}
community.docker.docker_image_build:
name: '{{ ci_base_image_name }}'
tag: '{{ ci_base_image_tag }}'
path: '{{ ci_base_image_contexte }}'
# RECONSTRUCTION CONDITIONNELLE : `never` en régime normal (le playbook ne
# rebâtit pas 3,3 Go à chaque passage), mais `always` dès que le Dockerfile
# a CHANGÉ sur la machine — c'est ce qui rend l'ajout d'une bibliothèque
# effectif sans avoir à penser à un drapeau.
rebuild: >-
{{ "always"
if (ci_base_image_force_rebuild or ci_base_image_dockerfile is changed)
else "never" }}
args:
CI_BASE_FROM: '{{ ci_base_image_from }}'
NODE_MAJOR: '{{ ci_base_image_node_major }}'
BUN_VERSION: '{{ ci_base_image_bun_version }}'
PLAYWRIGHT_VERSION: '{{ ci_base_image_playwright_version }}'
register: ci_base_image_build
# ⚠ CE CONTENEUR NE TOURNE JAMAIS — il ne sert qu'à référencer l'image.
# Remède décrit par docs/adr/20260407-docker-storage-gitea-runner.md §1, resté
# jusqu'ici à l'état de proposition : `system_docker.yml` n'applique que le
# data-root sur disque externe et les log-opts. Sans épinglage, le ramasse-miettes
# de Docker supprime l'image dès que le disque se remplit — panne DÉJÀ constatée
# sur les images de runner elles-mêmes, et qui casse la CI de tous les dépôts.
#
# `state: present` (et non `started`) : Docker considère l'image comme utilisée
# tant qu'un conteneur la référence, même à l'arrêt. Aucun CPU, aucune mémoire.
- name: Épingler {{ ci_base_image_name }} contre le ramasse-miettes Docker
community.docker.docker_container:
name: 'pin-{{ ci_base_image_name }}'
image: '{{ ci_base_image_name }}:{{ ci_base_image_tag }}'
state: present
command: ['sh', '-c', 'sleep infinity']
auto_remove: false
restart_policy: 'no'
when: ci_base_image_pin
# Contrôle de sortie : on VÉRIFIE que l'image répond, plutôt que de supposer que
# le build a suffi. Une image construite mais dont `bun` n'est pas dans le PATH
# passerait le build et casserait tous les jobs.
- name: Vérifier que l'image livre bien Node {{ ci_base_image_node_major }}, bun et chromium
ansible.builtin.command:
cmd: >-
docker run --rm {{ ci_base_image_name }}:{{ ci_base_image_tag }}
sh -c "node --version && bun --version && ls /ms-playwright && cat /etc/ci-base.versions"
register: ci_base_image_check
changed_when: false
# ⚠ La version de Node est VÉRIFIÉE, pas supposée : l'image de base en livre
# une trop ancienne (18), et une régression silencieuse ici casserait tout job
# Nuxt sur un message qui ne nomme pas la cause.
failed_when: >-
ci_base_image_check.rc != 0
or ('v' ~ ci_base_image_node_major ~ '.') not in ci_base_image_check.stdout
- name: Ce que l'image contient réellement
ansible.builtin.debug:
var: ci_base_image_check.stdout_lines
@@ -7,3 +7,37 @@ gitea_organization: arcodange-org
# URL de base du serveur Gitea
gitea_base_url: http://{{ groups.gitea[0] }}:3000
# Propriétaire du dépôt CÔTÉ GITEA. Par défaut l'organisation, pour ne rien
# changer aux dépôts déjà en miroir ; à surcharger (« arcodange ») pour les
# dépôts qui vivent sous le compte personnel.
gitea_repo_owner: "{{ gitea_organization }}"
# Propriétaires en FACE, forge par forge. Ils suivent le propriétaire Gitea par
# défaut, mais un dépôt personnel peut viser un compte personnel.
github_owner: "{{ github_organization }}"
gitlab_owner: "{{ gitlab_root_group }}"
# Un compte personnel n'est pas une organisation : GitHub ne crée pas un dépôt
# au même endroit (POST /user/repos contre POST /orgs/<org>/repos).
github_owner_is_org: true
# Identifiant du groupe OU de l'utilisateur GitLab qui accueille le projet.
# https://gitlab.com/groups/arcodange-org/-/edit
gitlab_namespace_id: 89826881
# Quelles forges recevoir en miroir. GitLab devient facultatif : sans ça, un
# échec côté GitLab avorte toute l'itération, y compris la partie GitHub.
gitea_mirror_github: true
gitea_mirror_gitlab: true
# Le miroir pousse ; c'est le dépôt d'en face qui doit être privé.
github_repo_private: true
gitlab_repo_visibility: private
# Nom d'utilisateur porté par le miroir push (le mot de passe est le jeton).
github_mirror_username: "{{ gitea_username }}"
gitlab_mirror_username: "{{ gitea_username }}"
# Cadence de rafraîchissement des miroirs, en plus du push à chaque commit.
gitea_mirror_interval: "8h"
@@ -1,6 +1,6 @@
- name: Vérifier si le dépôt existe dans Gitea
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}"
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
@@ -10,26 +10,36 @@
- name: Vérifier si le dépôt existe sur GitLab
uri:
url: "https://gitlab.com/api/v4/projects/{{ gitlab_root_group }}%2F{{ gitea_repo_name }}"
url: "https://gitlab.com/api/v4/projects/{{ gitlab_owner }}%2F{{ gitea_repo_name }}"
method: GET
headers:
Authorization: "Bearer {{ gitlab_api_token }}"
status_code: 200
register: gitlab_repo_check
ignore_errors: yes
when: gitea_mirror_gitlab | bool
- name: Vérifier si le dépôt existe sur GitHub
uri:
url: "https://api.github.com/repos/{{ github_organization }}/{{ gitea_repo_name }}"
url: "https://api.github.com/repos/{{ github_owner }}/{{ gitea_repo_name }}"
method: GET
headers:
Authorization: "token {{ github_api_token }}"
status_code: 200
register: github_repo_check
ignore_errors: yes
when: gitea_mirror_github | bool
# Une tâche sautée n'enregistre pas de « status » : sans le default(0), la
# condition suivante explose dès qu'une forge est désactivée.
- name: Retenir l'état de chaque forge
set_fact:
gitlab_repo_present: "{{ (gitlab_repo_check.status | default(0)) == 200 }}"
github_repo_present: "{{ (github_repo_check.status | default(0)) == 200 }}"
gitea_repo_present: "{{ (gitea_repo_check.status | default(0)) == 200 }}"
- name: Importer un dépôt GitLab/GitHub vers Gitea
when: gitea_repo_check.status != 200 and (gitlab_repo_check.status == 200 or github_repo_check.status == 200)
when: not gitea_repo_present and (gitlab_repo_present or github_repo_present)
uri:
url: "{{ gitea_base_url }}/api/v1/repos/migrate"
method: POST
@@ -38,16 +48,16 @@
status_code: 201
body_format: json
body:
service: "{{ (gitlab_repo_check.status == 200) | ternary('gitlab','github') }}"
service: "{{ gitlab_repo_present | ternary('gitlab','github') }}"
# URL du dépôt GitHub/GitLab
clone_addr: >-
{{ (gitlab_repo_check.status == 200) | ternary(gitlab_mirror_url,github_mirror_url) }}
{{ gitlab_repo_present | ternary(gitlab_mirror_url,github_mirror_url) }}
auth_username: "{{ gitea_username }}" # Nom d'utilisateur pour l'authentification si nécessaire
# token d'accès
auth_token: >-
{{ (gitlab_repo_check.status == 200) | ternary(gitlab_api_token,github_api_token) }}
{{ gitlab_repo_present | ternary(gitlab_api_token,github_api_token) }}
repo_name: "{{ gitea_repo_name }}" # Nom du dépôt dans Gitea
repo_owner: "{{ github_organization }}" # Propriétaire du dépôt dans Gitea (utilisateur ou organisation
repo_owner: "{{ gitea_repo_owner }}" # Propriétaire du dépôt dans Gitea (utilisateur ou organisation)
mirror: false # Activer le mirroring pour synchroniser les changements
register: migration_result
@@ -66,15 +76,20 @@
body:
name: "{{ gitea_repo_name }}"
path: "{{ gitea_repo_name }}"
namespace_id: "{{ gitlab_namespace_id }}" # Remplacez par l'ID du groupe ou de l'utilisateur où le projet doit être créé
visibility: "{{ gitlab_repo_visibility | default('private') }}" # Définir la visibilité (private, internal, public)
namespace_id: "{{ gitlab_namespace_id }}" # ID du groupe ou de l'utilisateur où le projet doit être créé
visibility: "{{ gitlab_repo_visibility }}" # Définir la visibilité (private, internal, public)
description: "{{ gitea_repo_description | default('') }}"
status_code: 201
when: gitlab_repo_check.status != 200
when: (gitea_mirror_gitlab | bool) and not gitlab_repo_present
# Un compte personnel n'a pas d'endpoint /orgs/<nom>/repos : GitHub crée alors
# le dépôt sous le compte porteur du jeton, via POST /user/repos.
- name: Créer un dépôt sur GitHub si nécessaire
uri:
url: "https://api.github.com/orgs/{{ github_organization }}/repos"
url: >-
{{ (github_owner_is_org | bool)
| ternary('https://api.github.com/orgs/' ~ github_owner ~ '/repos',
'https://api.github.com/user/repos') }}
method: POST
headers:
Authorization: "token {{ github_api_token }}"
@@ -82,13 +97,13 @@
body:
name: "{{ gitea_repo_name }}"
description: "{{ gitea_repo_description | default('') }}"
private: "{{ github_repo_private | default(true) }}" # Définir si le dépôt est privé ou public
private: "{{ github_repo_private | bool }}" # Définir si le dépôt est privé ou public
status_code: 201
when: github_repo_check.status != 200
when: (gitea_mirror_github | bool) and not github_repo_present
- name: Vérifier l'existence des miroirs push sur GitHub et GitLab
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}/push_mirrors"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}/push_mirrors"
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
@@ -97,32 +112,68 @@
- name: Ajouter un miroir push vers GitHub si nécessaire
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}/push_mirrors"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}/push_mirrors"
method: POST
headers:
Authorization: "token {{ gitea_api_token }}"
body_format: json
body:
interval: "8h"
interval: "{{ gitea_mirror_interval }}"
remote_address: "{{ github_mirror_url }}"
remote_username: "{{ gitea_username }}"
remote_username: "{{ github_mirror_username }}"
remote_password: "{{ github_api_token }}"
sync_on_commit: true
status_code: 200
when: "github_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list"
when:
- gitea_mirror_github | bool
- github_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list
- name: Ajouter un miroir push vers GitLab si nécessaire
uri:
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_organization }}/{{ gitea_repo_name }}/push_mirrors"
url: "{{ gitea_base_url }}/api/v1/repos/{{ gitea_repo_owner }}/{{ gitea_repo_name }}/push_mirrors"
method: POST
headers:
Authorization: "token {{ gitea_api_token }}"
body_format: json
body:
interval: "8h"
interval: "{{ gitea_mirror_interval }}"
remote_address: "{{ gitlab_mirror_url }}"
remote_username: "{{ gitea_username }}"
remote_username: "{{ gitlab_mirror_username }}"
remote_password: "{{ gitlab_api_token }}"
sync_on_commit: true
status_code: 200
when: "gitlab_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list"
when:
- gitea_mirror_gitlab | bool
- gitlab_mirror_url not in existing_mirrors.json | map(attribute='remote_address') | list
# Un dépôt GitHub créé vide adopte comme branche par défaut la PREMIÈRE branche
# que le miroir lui pousse — souvent une branche de travail, pas « main ». Le
# miroir étant asynchrone, l'alignement échoue au run qui crée le dépôt et
# réussit au suivant : d'où le failed_when permissif plutôt qu'un blocage.
- name: Aligner la branche par défaut de GitHub sur celle de Gitea
uri:
url: "https://api.github.com/repos/{{ github_owner }}/{{ gitea_repo_name }}"
method: PATCH
headers:
Authorization: "token {{ github_api_token }}"
body_format: json
body:
default_branch: "{{ gitea_repo_check.json.default_branch }}"
status_code: 200
register: github_default_branch
failed_when: false
when:
- gitea_mirror_github | bool
- gitea_repo_present
- (github_repo_check.json.default_branch | default('')) != gitea_repo_check.json.default_branch
- name: Signaler une branche par défaut encore désalignée
debug:
msg: >-
La branche par défaut de github.com/{{ github_owner }}/{{ gitea_repo_name }}
n'a pas pu être alignée sur « {{ gitea_repo_check.json.default_branch }} » :
le miroir ne l'a probablement pas encore poussée. Relancer après la synchro.
when:
- github_default_branch is defined
- github_default_branch is not skipped
- (github_default_branch.status | default(0)) != 200
@@ -3,8 +3,8 @@ gitlab_api_token: '{{ hostvars[groups.gitea[0]].gitea_vault.gitlab_api_token }}'
github_organization: '{{ gitea_organization }}'
gitlab_root_group: '{{ gitea_organization }}'
gitlab_namespace_id: 89826881 # https://gitlab.com/groups/arcodange-org/-/edit
# URLs des miroirs sur GitLab et GitHub
gitlab_mirror_url: "https://gitlab.com/{{ gitlab_root_group | default(gitlab_username | default(gitea_username)) }}/{{ gitea_repo_name }}.git"
github_mirror_url: "https://github.com/{{ github_organization | default(github_username | default(gitea_username)) }}/{{ gitea_repo_name }}.git"
# URLs des miroirs sur GitLab et GitHub — elles suivent le propriétaire visé sur
# chaque forge (cf. github_owner / gitlab_owner dans defaults/).
gitlab_mirror_url: "https://gitlab.com/{{ gitlab_owner }}/{{ gitea_repo_name }}.git"
github_mirror_url: "https://github.com/{{ github_owner }}/{{ gitea_repo_name }}.git"
@@ -5,3 +5,17 @@ gitea_organization: arcodange-org
gitea_base_url: http://{{ groups.gitea[0] }}:3000
gitea_token_fact_name: arcodange_factory_gitea_sync_token
# Propriétaire balayé. Par défaut l'organisation ; mettre « arcodange » et
# gitea_sync_owner_is_org à false pour balayer le compte personnel.
gitea_sync_owner: "{{ gitea_organization }}"
gitea_sync_owner_is_org: true
# Les trois API paginent (30 par défaut chez GitHub, 20 chez GitLab). Sous la
# taille d'une page, la différence entre forges désigne de faux dépôts manquants.
gitea_sync_page_size: 100
# Forges comparées. Balayer une forge qu'on ne veut pas alimenter ferait passer
# tous ses dépôts pour « incomplets ».
gitea_mirror_github: true
gitea_mirror_gitlab: true
@@ -1,40 +1,71 @@
# Un compte personnel n'est pas une organisation : ni GitHub ni GitLab ne
# servent ses dépôts au même endroit.
- name: Lister les dépôts de l'organisation GitHub
uri:
url: "https://api.github.com/orgs/{{ github_organization }}/repos"
url: >-
{{ (gitea_sync_owner_is_org | bool)
| ternary('https://api.github.com/orgs/' ~ github_owner ~ '/repos',
'https://api.github.com/users/' ~ github_owner ~ '/repos')
}}?per_page={{ gitea_sync_page_size }}
method: GET
headers:
Authorization: "token {{ github_api_token }}"
status_code: 200
register: github_repos
when: gitea_mirror_github | bool
- name: Lister les dépôts du groupe GitLab
uri:
url: "https://gitlab.com/api/v4/groups/{{ gitlab_root_group }}/projects"
url: >-
{{ (gitea_sync_owner_is_org | bool)
| ternary('https://gitlab.com/api/v4/groups/' ~ gitlab_owner ~ '/projects',
'https://gitlab.com/api/v4/users/' ~ gitlab_owner ~ '/projects')
}}?per_page={{ gitea_sync_page_size }}
method: GET
headers:
Authorization: "Bearer {{ gitlab_api_token }}"
status_code: 200
register: gitlab_repos
when: gitea_mirror_gitlab | bool
- name: Lister les dépôts de l'organisation Gitea
uri:
url: "{{ gitea_base_url }}/api/v1/orgs/{{ gitea_organization }}/repos"
url: >-
{{ (gitea_sync_owner_is_org | bool)
| ternary(gitea_base_url ~ '/api/v1/orgs/' ~ gitea_sync_owner ~ '/repos',
gitea_base_url ~ '/api/v1/users/' ~ gitea_sync_owner ~ '/repos')
}}?limit={{ gitea_sync_page_size }}
method: GET
headers:
Authorization: "token {{ gitea_api_token }}"
status_code: 200
register: gitea_repos
# Une forge désactivée ne doit pas peser dans la différence : on la remplace par
# la liste Gitea elle-même, qui la rend neutre à l'intersection.
- name: Établir la liste des dépôts incomplets
set_fact:
gitea_repo_names: "{{ gitea_repos.json | map(attribute='name') | list }}"
github_repo_names: >-
{{ (gitea_mirror_github | bool)
| ternary(github_repos.json | default([]) | map(attribute='name') | list,
gitea_repos.json | map(attribute='name') | list) }}
gitlab_repo_names: >-
{{ (gitea_mirror_gitlab | bool)
| ternary(gitlab_repos.json | default([]) | map(attribute='name') | list,
gitea_repos.json | map(attribute='name') | list) }}
- name: Réduire aux dépôts absents d'au moins une forge
set_fact:
repos_incomplete: >-
{{ (github_repo_names | union(gitlab_repo_names) | union(gitea_repo_names))
| difference(github_repo_names | intersect(gitlab_repo_names) | intersect(gitea_repo_names)) }}
- name: Synchroniser
include_role:
name: arcodange.factory.gitea_repo
vars:
github_repo_names: "{{ github_repos.json | map(attribute='name') | list }}"
gitlab_repo_names: "{{ gitlab_repos.json | map(attribute='name') | list }}"
gitea_repo_names: "{{ gitea_repos.json | map(attribute='name') | list }}"
all_repos: "{{ github_repo_names | union(gitlab_repo_names) | union(gitea_repo_names) }}"
repos_common_to_all: "{{ github_repo_names | intersect(gitlab_repo_names) | intersect(gitea_repo_names) }}"
repos_incomplete: "{{ all_repos | difference(repos_common_to_all) }}"
gitea_repo_owner: "{{ gitea_sync_owner }}"
loop: "{{ repos_incomplete }}"
loop_control:
loop_var: gitea_repo_name
@@ -3,3 +3,7 @@ gitlab_api_token: '{{ hostvars[groups.gitea[0]].gitea_vault.gitlab_api_token }}'
github_organization: '{{ gitea_organization }}'
gitlab_root_group: '{{ gitea_organization }}'
# Les propriétaires en face suivent celui qu'on balaie côté Gitea.
github_owner: '{{ gitea_sync_owner }}'
gitlab_owner: '{{ gitea_sync_owner }}'
+11
View File
@@ -24,6 +24,14 @@ spec:
destination:
server: https://kubernetes.default.svc
namespace: {{ $ns }}
{{- /* Champs à exclure du diff (ex: /spec/volumeName d'un PVC rebindé à la
main après le drill coupure de courant — immuable côté API). À coupler
avec la syncOption RespectIgnoreDifferences=true pour que l'apply
réinjecte la valeur live au lieu de tenter de la vider. */}}
{{- with $app_attr.ignoreDifferences }}
ignoreDifferences:
{{- toYaml . | nindent 4 }}
{{- end }}
syncPolicy:
{{- if $app_attr.syncPolicy }}
{{- toYaml $app_attr.syncPolicy | nindent 4 }}
@@ -34,6 +42,9 @@ spec:
{{- end }}
syncOptions:
- CreateNamespace=true
{{- range $app_attr.syncOptions }}
- {{ . }}
{{- end }}
{{- /*
Non-prod environments (ADR-0002 elision rule): one extra Application per env
under `<app_attr>.envs`. Each renders the SAME repo + chart, overlaid with
+27
View File
@@ -4,6 +4,15 @@
gitea_applications:
url-shortener:
annotations: {}
# Le PVC live a un spec.volumeName épinglé (rebind du volume Longhorn) que
# le chart ne déclare pas : sans ceci, chaque sync tente de le vider et
# l'API le refuse (spec immuable) → SyncError permanent.
ignoreDifferences:
- kind: PersistentVolumeClaim
jsonPointers:
- /spec/volumeName
syncOptions:
- RespectIgnoreDifferences=true
tools:
annotations: {}
syncPolicy:
@@ -51,6 +60,24 @@ gitea_applications:
annotations:
argocd-image-updater.argoproj.io/image-list: kadans-jobs=gitea.arcodange.lab/arcodange/kadans-jobs:latest
argocd-image-updater.argoproj.io/kadans-jobs.update-strategy: digest
kadans-admin:
org: arcodange
# Backoffice de modération : ni base ni secret Vault propres (compagnon
# « sans état », cf. factory/doc/runbooks/new-web-app/09). La sécurité
# applicative vit côté kadans-api (requireAdmin, KADANS_ADMIN_EMAILS) ;
# ce chart n'expose qu'un ingress .lab.
namespace: kadans
annotations:
argocd-image-updater.argoproj.io/image-list: kadans-admin=gitea.arcodange.lab/arcodange/kadans-admin:latest
argocd-image-updater.argoproj.io/kadans-admin.update-strategy: digest
kadans-api:
org: arcodange
# L'API cœur partage le stack Vault/DB « kadans » (VaultAuth, creds Postgres,
# policy KV) : elle vit donc dans le namespace de l'app front qu'elle sert.
namespace: kadans
annotations:
argocd-image-updater.argoproj.io/image-list: kadans-api=gitea.arcodange.lab/arcodange/kadans-api:latest
argocd-image-updater.argoproj.io/kadans-api.update-strategy: digest
argocd_image_updater_chart_values:
config:
+111
View File
@@ -0,0 +1,111 @@
[← ADRs](.) · [factory](../..) · **20260726 — stockage objet (MinIO) : qui déclare quoi**
> **Cross-references** (bidirectionnel : chaque fichier listé doit citer cette ADR en tête)
>
> - **Infra partagée** (repo `arcodange-org/tools`) :
> [`minio/iac/modules/minio_app/`](https://gitea.arcodange.lab/arcodange-org/tools/src/branch/main/minio/iac/modules/minio_app) ·
> [`minio/iac/provisioner.tf`](https://gitea.arcodange.lab/arcodange-org/tools/src/branch/main/minio/iac/provisioner.tf) ·
> [`minio/values.yaml`](https://gitea.arcodange.lab/arcodange-org/tools/src/branch/main/minio/values.yaml) ·
> [`hashicorp-vault/iac/modules/app_policy/main.tf`](https://gitea.arcodange.lab/arcodange-org/tools/src/branch/main/hashicorp-vault/iac/modules/app_policy/main.tf)
> - **Premier consommateur** (repo `arcodange/kadans`) :
> [`iac/main.tf`](https://gitea.arcodange.lab/arcodange/kadans/src/branch/main/iac/main.tf)
> - **API consommatrice** (repo `arcodange/kadans-api`) :
> [`stockage.go`](https://gitea.arcodange.lab/arcodange/kadans-api/src/branch/main/stockage.go) ·
> [`chart/values.yaml`](https://gitea.arcodange.lab/arcodange/kadans-api/src/branch/main/chart/values.yaml)
> - **Related ADR** :
> [`04_tool_hashicorp_vault.md`](04_tool_hashicorp_vault.md) (rôles et politiques Vault) ·
> [`20260407-network-architecture.md`](20260407-network-architecture.md) (Cloudflare / Traefik / CrowdSec)
# ADR 20260726 : stockage objet (MinIO) — qui déclare quoi, et qui détient quoi
## Status
Proposed
## Context
MinIO est déployé dans le namespace `tools` (chart officiel, standalone, volume Longhorn). Le premier consommateur est Kadans, qui doit téléverser des rendus vidéo depuis le navigateur pour qu'ils suivent l'utilisateur d'un appareil à l'autre.
Trois questions se posaient, et elles sont indépendantes :
1. **Qui déclare les buckets** d'une application ?
2. **Qui détient les identifiants** capables de les créer ?
3. **Comment l'application lit** les siens à l'exécution ?
Une première version faisait tout porter par `tools` : une liste de consommateurs dans son Terraform, les buckets dans son chart. Elle a été rejetée — à ce rythme, chaque bucket de chaque application devient une PR sur l'infra partagée, et le dépôt commun devient le goulot de tout le monde.
## Decision
### 1. Chacun son périmètre
**Une application déclare ses buckets depuis son propre dépôt.** `tools` fournit le serveur, un module de standardisation, et un compte de provisionnement — **pas la liste**.
```hcl
# iac/main.tf de l'application
module "stockage" {
source = "git::…/tools.git//minio/iac/modules/minio_app?depth=1&ref=main"
app = "kadans"
buckets = ["kadans-videos"]
providers = { minio = minio }
}
```
Le module crée les buckets (privés), une politique bornée à ces buckets, un compte de service, et écrit ses clés dans `kvv2/minio/<app>`.
### 2. Trois identités, trois portées
| Identité | Peut | Ne peut pas | Qui la lit |
|---|---|---|---|
| **root** MinIO | tout | — | le seul pipeline `minio` (`kvv2/minio/config`) |
| **provisionneur** | créer bucket, politique, compte de service | lire ou écrire un objet | le rôle **CI** de chaque app (`kvv2/minio/provisioner`) |
| **compte de service** d'une app | lire/écrire dans **ses** buckets | tout le reste | le **pod** de l'app (`kvv2/minio/<app>`) |
C'est la pièce qui rend le point 1 possible. Provisionner demande des droits d'administration ; confier le **root** aurait donné à chaque application la lecture des objets de **toutes** les autres. Le provisionneur, lui, peut créer des buckets — une nuisance si une app est compromise — mais **pas lire les vidéos d'une autre**.
### 3. La lecture est une propriété de la plateforme
Le module Vault central `app_policy` accorde à **toute** application la lecture de `kvv2/data/minio/<son nom>`, **inconditionnellement**.
Pas de drapeau, pas de déclaration par app : le chemin porte le nom de l'application, donc la règle **ne peut jamais exposer que ses propres clés**. Une app qui ne stocke rien y lit un chemin qui n'existe pas — une règle inerte, pas un privilège.
Conséquence pratique : déclarer un consommateur se fait à **un seul endroit**, son propre `iac/`. Rien à synchroniser, donc rien à oublier.
### 4. Les octets ne passent pas par l'API
L'application signe des **URL présignées** ; le navigateur téléverse **directement** vers MinIO. Faire transiter 50 à 200 Mo par un pod applicatif doublerait le transit et exposerait l'API à un seul gros fichier.
Corollaires :
- l'endpoint signé doit être **joignable par le navigateur**, donc **public** (`s3.arcodange.fr`) — une page servie en HTTPS ne peut pas téléverser vers `http://` (contenu mixte), et un TLD interne ne se résout pas hors du LAN ;
- **CORS** liste les origines **exactes** de l'application, jamais `*` : une URL présignée qui fuiterait serait sinon rejouable depuis n'importe quel site ;
- l'ingress public ne porte **pas** de basic-auth, contrairement aux autres : une requête S3 porte sa propre signature, et un défi HTTP Basic casserait un PUT présigné auquel le navigateur ne peut pas répondre.
### 5. Un bucket par cycle de vie, pas par application
Une application peut avoir plusieurs buckets. Deux contenus aux durées de vie différentes méritent deux politiques de purge — Kadans en aura deux (un rendu de travail à garder, un aperçu régénérable).
Le compte de service est **par application** : ajouter un bucket ne crée aucune clé, le compte existant gagne l'accès.
## Consequences
- **Le dépôt `tools` n'est plus modifié** quand une application change ses buckets. C'était l'objet de la décision.
- **Ordre de déploiement contraint** : le module doit exister sur `main` de `tools` avant qu'une application l'appelle (`?ref=main`), et le provisionneur doit exister avant le premier plan d'application.
- **Le provisionneur est un secret partagé** entre les rôles CI. Sa compromission permet de créer des buckets et des comptes, pas de lire des objets. Si ce risque devient inacceptable, la suite est une identité de provisionnement **par application**, bornée par préfixe de bucket — MinIO ne le permet pas simplement aujourd'hui.
- **Vérifié par le premier `apply`** (kadans, 2026-07-26) : les actions d'administration passent telles quelles — compte de service, politique et attachement ont été créés. Une seule correction a été nécessaire côté S3, `s3:ListBucket` : le provider interroge l'existence du bucket (HeadBucket) avant de le créer, et MinIO répond `Access Denied` sans cette action. Elle donne au provisionneur la vue des **clés** d'un bucket, jamais leur **contenu** — la garantie « ne peut pas lire les vidéos d'une autre app » tient toujours.
## Alternatives Considered
| Option | Pourquoi non |
|---|---|
| `tools` détient la liste des consommateurs | Chaque bucket de chaque app devient une PR sur l'infra partagée — rejeté par le fondateur, et c'est le cœur de cette ADR |
| Les buckets déclarés dans le chart de MinIO (`values.yaml`) | Même défaut : la déclaration vit chez l'infra, pas chez l'application |
| Chaque app crée son compte de service avec le **root** | Le root lit et écrit tous les objets de toutes les apps : le distribuer à chaque rôle CI revient à ne plus avoir de cloisonnement |
| Déclarer la lecture Vault par app (`kv_read_paths`) | Mécanisme réel, mais c'est la trappe pour lire un secret appartenant à une **autre** app (creds GCS de Longhorn pour l'ERP). Y ranger un motif standard le rend invisible et oblige à le redéclarer partout |
| Un drapeau `object_storage = true` par app | Une déclaration de plus à tenir synchronisée avec le `iac/` de l'app — donc une à oublier. La règle inerte ne coûte rien |
| Une identité de provisionnement par app | Souhaitable, mais MinIO ne borne pas simplement les actions d'administration par préfixe. À reconsidérer si le modèle de menace change |
## Success Metrics
- Ajouter une application consommatrice ne touche **aucun** fichier de `tools`.
- Un compte de service compromis ne donne accès qu'aux objets qu'il gérait déjà.
- Le root de MinIO n'apparaît dans aucune politique Vault en dehors du pipeline `minio`.
+1
View File
@@ -15,6 +15,7 @@
- [x] gitea packages
- [ ] devsecops tools
- [x] [hashicorp vault](./04_tool_hashicorp_vault.md)
- [x] [stockage objet MinIO — qui déclare quoi](./20260726-stockage-objet-minio.md)
- [ ] terrakube
- [ ] prometheus/grafana
- [ ] ansible AWX
@@ -39,6 +39,7 @@ Options supplémentaires :
| Champ | Quand l'utiliser | Effet |
|---|---|---|
| `org: arcodange` | dépôt hors `arcodange-org` | change le `repoURL` (défaut `arcodange-org`) |
| `namespace: <autre>` | **service compagnon** partageant le namespace d'une app existante | déploie hors du namespace `<app>` (défaut = nom de l'app) — voir [9. Service compagnon](09-service-compagnon.md) |
| `syncPolicy: …` | contrôle manuel | surcharge la policy (défaut : `automated {prune, selfHeal}`) |
## Ce que ça génère
@@ -79,7 +80,7 @@ flowchart LR
## Notes / contraintes
> [!IMPORTANT]
> `path: chart` et `namespace: <app>` sont **déduits du nom**, pas configurables par entrée. C'est pourquoi le dossier doit s'appeler `chart/` ([étape 1](01-gitea-repo.md)) et le nom doit être cohérent partout ([conventions](conventions.md)).
> `path: chart` est **fixe** (jamais configurable) : c'est pourquoi le dossier doit s'appeler `chart/` ([étape 1](01-gitea-repo.md)) et le nom doit être cohérent partout ([conventions](conventions.md)). Le `namespace` vaut **le nom de l'app par défaut**, mais se surcharge via `namespace:` — utilisé par les [services compagnons](09-service-compagnon.md) qui partagent le namespace d'une app existante.
- Le chart `factory/argocd` est lui-même réconcilié par ArgoCD (app-of-apps racine) : committer `values.yaml` sur `main` suffit à faire apparaître/synchroniser la nouvelle `Application`. Pas de `kubectl apply` manuel.
- `prune: true` + `selfHeal: true` : ArgoCD supprime ce qui n'est plus dans le chart et réécrase les dérives manuelles. En tenir compte avant tout `kubectl edit`.
@@ -88,4 +89,5 @@ flowchart LR
- [4. Chart Helm](04-helm-chart.md) — le contenu déployé (le dossier `chart/`).
- [6. Workflows CI](06-ci-workflows.md) — les annotations `argocd-image-updater` collaborent avec l'image poussée.
- [9. Service compagnon](09-service-compagnon.md) — le champ `namespace:` pour déployer dans le namespace d'une app existante.
- [8. Checklist](08-checklist.md) — vérifier que l'`Application` passe `Healthy`/`Synced`.
@@ -0,0 +1,146 @@
[Factory](../../../README.md) > [Doc](../../README.md) > [Runbooks](../README.md) > [Nouvelle application web](README.md) > **9. Service compagnon**
# 9. Service compagnon (namespace partagé)
> **Status:** ✅ Active
> **Upstream:** [7. Enregistrement ArgoCD](07-argocd-register.md) (le champ `namespace:` utilisé ici)
> **Related:** [Conventions de nommage](conventions.md) · [4. Chart Helm](04-helm-chart.md) · [2. Base de données](02-database.md) · [Checklist](08-checklist.md)
---
## Summary
Tous les autres chapitres décrivent une app **autonome** : son dépôt, sa base, son stack Vault, son namespace, son ServiceAccount — tout porte le même nom `<app>`. Mais certains services ne sont pas une app à part entière : ce sont des **compagnons** d'une app existante. Une **API cœur** à côté de son front, une **façade d'analyse** qui sert une app — ils vivent dans le **même namespace** que l'app qu'ils servent et **réutilisent son identité** (Vault, base, ServiceAccount) plutôt que d'en provisionner une nouvelle.
Ce chapitre décrit ce raccourci et son **piège principal** : la convention « tout est nommé `<app>` » ([conventions](conventions.md)) **ne tient plus** pour un compagnon — ses identités Vault/DB/SA restent celles de l'app **primaire**, pas les siennes.
## Compagnon ou app autonome ?
Fais un **compagnon** quand le service partage réellement l'identité et les données de l'app primaire. Fais une **app autonome** (chapitres 1→8) dès qu'il lui faut sa propre base ou ses propres accès.
| Prends un compagnon si… | Prends une app autonome si… |
|---|---|
| Il lit/écrit **la base de l'app primaire** (même données) | Il lui faut **sa propre base** |
| Il partage le cycle de vie de l'app (déployé avec, pour elle) | Il a un cycle de vie indépendant |
| Une seule origine CORS / un seul domaine logique | Domaine et exposition propres |
Un compagnon garde **son propre dépôt Gitea et son propre chart** (donc sa propre image, sa CI de build, son ingress). Ce qu'il **ne** refait pas : base, rôles Vault, ServiceAccount, namespace.
## Les deux formes de compagnon
### A. Compagnon sans état — juste le namespace partagé
Le service n'a **ni base ni secret Vault** (ex. façade d'analyse `kadans-jobs`). Il suffit de le déployer dans le namespace de l'app primaire. Une seule chose le distingue d'une app normale à l'[étape 7](07-argocd-register.md) : la clé **`namespace:`**.
```yaml
# factory/argocd/values.yaml
kadans-jobs:
org: arcodange
namespace: kadans # ← sinon ArgoCD déduirait « kadans-jobs »
annotations:
argocd-image-updater.argoproj.io/image-list: kadans-jobs=…/kadans-jobs:latest
argocd-image-updater.argoproj.io/kadans-jobs.update-strategy: digest
```
Son chart ne contient que `deployment` / `service` / `ingress`. **Pas d'`iac/`, rien dans `postgres/iac/terraform.tfvars`, pas de CRD Vault.**
### B. Compagnon partageant le stack Vault/DB de l'app primaire
Le service lit la **base de l'app primaire** avec **ses** creds dynamiques (ex. API cœur `kadans-api` sur la base `kadans`). Il réutilise, **à l'identique**, tout ce qui a été provisionné pour le primaire :
| Ressource | Elle porte le nom du **primaire**, jamais du compagnon |
|---|---|
| Base PostgreSQL | `kadans` (pas `kadans-api`) |
| Rôle DB dynamique Vault | `postgres/creds/kadans` |
| Rôle d'auth K8s Vault | `kadans` (bound au SA `kadans` / ns `kadans`) |
| Policy KV runtime | `kadans` (accès `kvv2/kadans/*`) |
| ServiceAccount K8s | `kadans` (créé par le chart du **primaire**) |
Donc le compagnon **ne fait PAS** l'[étape 2](02-database.md) (pas de nouvelle base), **PAS** l'[étape 5](05-app-terraform.md) (pas de nouvel `app_roles`, pas d'`iac/`), et **ne s'ajoute PAS** à la liste `applications` de `postgres` / `tools`. Son `VaultDynamicSecret` pointe simplement le mount/chemin du primaire :
```yaml
# chart du compagnon — vaultdynamicsecret.yaml
spec:
mount: postgres
path: creds/kadans # = le rôle DB du PRIMAIRE
vaultAuthRef: kadans # cf. le VaultAuth ci-dessous
```
Le host DB reste **`pgbouncer.tools`**, base = celle du primaire ([étape 4](04-helm-chart.md) « via pgbouncer, jamais en direct »).
> [!IMPORTANT]
> **Le piège du VaultAuth manquant.** Le `VaultDynamicSecret` a besoin d'un CR **`VaultAuth`** dans le namespace (VSO le résout par nom, dans le même namespace). Deux cas :
>
> - **Le primaire consomme déjà Vault** → son chart a déjà posé un `VaultAuth` (nommé `auth` par convention, [étape 4](04-helm-chart.md)). Le compagnon **le référence** (`vaultAuthRef: auth`) et ne crée rien.
> - **Le primaire ne consomme PAS Vault** (front statique, aucune base — cas de `kadans`) → **personne** n'a créé de `VaultAuth` dans le namespace. Le compagnon doit alors **poser le sien**, mais pointant le rôle et le SA du **primaire** :
>
> ```yaml
> # chart du compagnon — vaultauth.yaml (cas « primaire sans Vault »)
> apiVersion: secrets.hashicorp.com/v1beta1
> kind: VaultAuth
> metadata:
> name: kadans # ou « auth » ; l'important est spec.kubernetes.*
> namespace: {{ .Release.Namespace }}
> spec:
> # PAS de vaultConnectionRef → VSO retombe sur sa connexion globale (comme erp/webapp).
> method: kubernetes
> mount: kubernetes
> kubernetes:
> role: kadans # ← rôle K8s Vault du PRIMAIRE
> serviceAccount: kadans # ← SA du PRIMAIRE (créé par SON chart)
> audiences: [vault]
> ```
>
> C'est la seule raison pour laquelle le SA et le rôle du VaultAuth ne portent **pas** le nom du service qui le déploie. Ne crée **pas** un second SA `kadans-api` : le rôle K8s Vault `kadans` n'accepte que le SA `kadans`.
> [!WARNING]
> **N'écris PAS `vaultConnectionRef: default` dans un namespace applicatif.** VSO résout `vaultConnectionRef` **dans le namespace du CR** — or la VaultConnection `default` n'existe que dans le namespace **`tools`**. La nommer explicitement ailleurs fait chercher `<ns>/default` (inexistant) : le `VaultDynamicSecret` reste bloqué sur `VaultConnection "default" not found`, le Secret n'est jamais matérialisé, et le pod tourne en `CreateContainerConfigError`. Les apps hors `tools` (erp, webapp) **omettent** ce champ et laissent VSO utiliser sa `defaultVaultConnection`. (crowdsec/plausible peuvent l'écrire car ils vivent **dans** `tools`.)
## Carte
```mermaid
%%{init: {'theme': 'base'}}%%
flowchart TB
classDef prim fill:#059669,stroke:#047857,color:#fff
classDef comp fill:#b45309,stroke:#92400e,color:#fff
classDef sh fill:#7c3aed,stroke:#6d28d9,color:#fff
subgraph NS["namespace « kadans »"]
SA["ServiceAccount kadans<br>(chart du PRIMAIRE)"]:::sh
VA["VaultAuth<br>role kadans · SA kadans"]:::sh
PRIM["Deployment kadans<br>(front, sans Vault)"]:::prim
COMP["Deployment kadans-api<br>(compagnon, lit la base)"]:::comp
end
VA -->|"vaultAuthRef"| VDS["VaultDynamicSecret<br>postgres/creds/kadans"]:::sh
COMP --> VA
VDS --> COMP
COMP --> PGB["pgbouncer.tools → base kadans"]:::sh
SA -.->|"identité empruntée"| VA
```
## Précédents vivants
| Compagnon | Primaire | Partage | CRD Vault dans son chart |
|---|---|---|---|
| [`kadans-jobs`](https://gitea.arcodange.lab/arcodange/kadans-jobs) | `kadans` | namespace seul (sans état) | aucun |
| [`kadans-api`](https://gitea.arcodange.lab/arcodange/kadans-api) | `kadans` | namespace + base + Vault | `vaultauth` (le primaire n'a pas de Vault) + `vaultdynamicsecret` |
## Delta de checklist
Par rapport à la [checklist standard](08-checklist.md), un compagnon **saute** :
- ❌ [Étape 2](02-database.md) — pas de nouvelle base ni de rôle propriétaire.
- ❌ [Étape 5](05-app-terraform.md) — pas d'`iac/`, pas d'`app_roles`, rien à ajouter aux listes `applications`.
…et **ajuste** :
- ✅ [Étape 4](04-helm-chart.md) — `VaultDynamicSecret` pointe `creds/<primaire>` ; poser un `vaultauth.yaml` **seulement** si le primaire ne consomme pas déjà Vault (rôle + SA = ceux du primaire).
- ✅ [Étape 7](07-argocd-register.md) — ajouter `namespace: <primaire>` à l'entrée `gitea_applications`.
- ✅ Ordre de merge : le fix/chart du compagnon **avant** son enregistrement ArgoCD, pour que la 1ʳᵉ synchro parte d'un chart correct.
## Related
- [7. Enregistrement ArgoCD](07-argocd-register.md) — le champ `namespace:` qui place le compagnon dans le namespace du primaire.
- [4. Chart Helm](04-helm-chart.md) — la forme des CRD VSO et la connexion via `pgbouncer.tools`.
- [Conventions de nommage](conventions.md) — la règle « tout est `<app>` » que ce chapitre nuance pour un compagnon.
- [Référence VSO faisant autorité](https://gitea.arcodange.lab/arcodange-org/tools/src/branch/main/hashicorp-vault/iac/modules/README.md) — VaultConnection/VaultAuth/VaultDynamicSecret côté `tools`.
+2 -1
View File
@@ -2,7 +2,7 @@
# Mettre en service une nouvelle application web
> **Last Updated:** 2026-07-12
> **Last Updated:** 2026-07-24
> **Status:** ✅ Procédure courante
> **Related:** [Conventions de nommage](conventions.md) · [Checklist](08-checklist.md) · [ADR CI/CD](../../adr/03_cicd_gitea_action_argocd.md) · [ADR Vault](../../adr/04_tool_hashicorp_vault.md)
@@ -92,6 +92,7 @@ Ces fondations existent et ne sont **pas** à refaire pour chaque app :
| 06b | [CI des apps Bun/Nuxt](06b-bun-nuxt-ci.md) | `.gitea/workflows/ci.yml` : gates lint/test/`nuxt build` + piège Node 18→20 | ✅ |
| 07 | [Enregistrement ArgoCD](07-argocd-register.md) | `factory/argocd/values.yaml` → Application + déploiement | ✅ |
| 08 | [Checklist](08-checklist.md) | Récapitulatif ordonné + definition of done | ✅ |
| 09 | [Service compagnon](09-service-compagnon.md) | Un service qui partage le namespace + stack Vault/DB d'une app existante (ex. API cœur, façade) | ✅ |
## Légende de statut
+4
View File
@@ -44,6 +44,9 @@ Les briques se « branchent » entre elles **par convention de nom**, pas par co
**Utilise un nom court, stable, kebab-case** dès le départ.
**N'introduis pas** de variantes (`my_app` vs `my-app`, `MyApp`, pluriels) : rien ne te préviendra, l'app échouera silencieusement à se connecter ou à se déployer.
> [!NOTE]
> **Exception : les services compagnons.** Un service qui partage le namespace et le stack d'une app existante (ex. une API cœur à côté de son front) **emprunte l'identité du primaire** — sa base, son rôle Vault et son ServiceAccount portent le nom du **primaire**, pas le sien. La règle « tout est `<app>` » ne vaut alors que pour son dépôt, son chart et son image. Voir [9. Service compagnon](09-service-compagnon.md).
## Plusieurs environnements pour une même app
Une application peut être déployée plusieurs fois (prod, sandbox, …) **sans devenir une app distincte** : même dépôt, même chart, même version. On ajoute une seconde coordonnée `<env>` au nom, régie par une **règle d'élision** ([ADR-0002](../../../vibe/ADR/0002-per-application-environments.md)) :
@@ -76,3 +79,4 @@ Déclaration : `postgres/iac/terraform.tfvars` et la liste `applications` côté
- [05 · Terraform de l'app](05-app-terraform.md) — appelle `app_roles` avec `name=<app>`.
- [06 · Workflows CI](06-ci-workflows.md) — s'authentifie avec `gitea_cicd_<app>`.
- [07 · Enregistrement ArgoCD](07-argocd-register.md) — déclare `<app>` dans `gitea_applications`.
- [09 · Service compagnon](09-service-compagnon.md) — l'exception : un compagnon emprunte l'identité de l'app primaire.
@@ -0,0 +1,81 @@
[vibe](../../../README.md) > [Guidebooks](../../README.md) > [Factory provisioning](../README.md) > [Ansible](README.md) > **07 · Mirrors**
# 07 · Mirrors — Gitea → GitHub / GitLab
> [!NOTE]
> **Status:** ✅ active · **Last Updated:** 2026-07-27
> **Upstream:** [Ansible sub-hub](README.md) · [Factory provisioning hub](../README.md)
> **Downstream:** [Roles reference](roles.md) — `gitea_repo`, `gitea_sync`, `gitea_token`
> **Related:** [Inventory & variables](inventory.md) · [03 · CI/CD](03-cicd.md)
Gitea is the **source of truth**; GitHub and GitLab hold a pushed copy. [`playbooks/07_mirrors.yml`](../../../../ansible/arcodange/factory/playbooks/07_mirrors.yml) walks the repos declared in `gitea_mirrored_repos` ([`inventory/group_vars/all/gitea.yml`](../../../../ansible/arcodange/factory/inventory/group_vars/all/gitea.yml)) and, for each, calls [`gitea_repo`](../../../../ansible/arcodange/factory/roles/gitea_repo): create the counterpart repo **private** if it is missing, then attach a push mirror refreshed every **8 h** *and* on **every commit**.
Nothing is pulled back. A mirror only ever pushes Gitea → forge, so a change made on GitHub is overwritten at the next sync.
```sh
uv run ansible-playbook -i ansible/arcodange/factory/inventory \
ansible/arcodange/factory/playbooks/07_mirrors.yml
# GitHub only — while the personal GitLab namespace is still unset:
… -e gitea_mirror_gitlab=false
```
The Gitea token is minted for the run by `gitea_token` and **revoked in `post_tasks`**. Everything is tagged `gitea_mirrors`.
---
## Two ways to pick repos, and when each fits
| | [`gitea_sync`](../../../../ansible/arcodange/factory/roles/gitea_sync) | `gitea_mirrored_repos` + `07_mirrors.yml` |
| --- | --- | --- |
| Selection | Automatic: diffs the three forges for **one owner**, reconciles whatever is missing somewhere | Explicit list, reviewed in the inventory |
| Fits | The organisation, where every repo is meant to exist everywhere | The personal account, where each repo leaving the homelab is a deliberate call |
| Blind spot | `repos_incomplete = all common` says nothing about *why* a repo is missing — a repo deleted on purpose from GitHub is recreated | Anything absent from the list is silently never mirrored |
Both drive the same `gitea_repo` role, so the mirror they produce is identical.
---
## Owner mapping
A Gitea repo owned by the **user** `arcodange` does not belong on the GitHub **organisation** — and GitHub does not even create it the same way (`POST /user/repos` instead of `POST /orgs/<org>/repos`). Hence three knobs, all defaulting to the previous org-only behaviour:
| Var | Default | Meaning |
| --- | --- | --- |
| `gitea_repo_owner` | `gitea_organization` | Owner **on Gitea** |
| `github_owner` / `gitlab_owner` | `github_organization` / `gitlab_root_group` | Owner **on the far forge** |
| `github_owner_is_org` | `true` | `false` routes creation to `POST /user/repos` |
| `gitea_mirror_github` / `gitea_mirror_gitlab` | `true` | Turn a forge off entirely |
> [!IMPORTANT]
> GitLab was **not optional** before. Its create call expected `201` with no `ignore_errors`, so a GitLab failure aborted the iteration — including the GitHub half that had nothing to do with it. `gitea_mirror_gitlab: false` is the way out.
> [!WARNING]
> A GitHub repo created **empty** adopts as its default branch the *first branch the mirror pushes*, which is routinely a work branch rather than `main`. The role realigns it against Gitea's default branch, but the mirror is asynchronous: the alignment fails on the run that creates the repo and succeeds on the next one. Run the playbook twice, or fix the branch by hand.
---
## Current state (2026-07-27)
| Owner | Repos mirrored | Target |
| --- | --- | --- |
| `arcodange-org` | 10 (`factory`, `tools`, `erp`, `cms`, `webapp`, `url-shortener`, `docker.tofu`, `docker-build-workflow`, `super-linter-workflow`, `vault-action`) | `github.com/arcodange-org/*` + GitLab |
| `arcodange` (user) | 5 (`kadans`, `kadans-api`, `kadans-dossier`, `kadans-jobs`, `video_analysis`) — all **private** | `github.com/arcodange/*` |
Not mirrored, deliberately left out of `gitea_mirrored_repos`: `documents`, `studio`, `prospection`, `kissmetrics_contract_proposal` (org) and `.profile`, `DanceVideos`, `SecondBrain`, `dance-lessons-coach`, `frame-sdk`, `telegram-gateway` (user).
> [!NOTE]
> The personal repos have **no GitLab mirror yet**: `gitlab_personal_namespace_id` is still `~`. Fill it with the numeric namespace ID of the `arcodange` account on gitlab.com, otherwise creation would land the project in the `arcodange-org` group.
---
## Reading the truth from Gitea
The push mirrors live in Gitea, not in this repo — the playbook is idempotent precisely because it asks first:
```sh
curl -s -H "Authorization: token $GITEA_TOKEN" \
https://gitea.arcodange.lab/api/v1/repos/arcodange/kadans/push_mirrors
```
`last_update` tells you when the mirror last pushed. A repo with no entry has no mirror, whatever this page claims.
@@ -5,7 +5,7 @@
> [!NOTE]
> **Status:** ✅ active · **Last Updated:** 2026-06-23
> **Upstream:** [Factory provisioning hub](../README.md) · [Lab ecosystem · 01 factory](../../lab-ecosystem/01-factory.md)
> **Downstream:** [01 · System](01-system.md) · [02 · Setup](02-setup.md) · [03 · CI/CD](03-cicd.md) · [04 · Tools](04-tools.md) · [05 · Backup](05-backup.md) · [06 · Recover](06-recover.md) · [Inventory & variables](inventory.md) · [Roles reference](roles.md)
> **Downstream:** [01 · System](01-system.md) · [02 · Setup](02-setup.md) · [03 · CI/CD](03-cicd.md) · [04 · Tools](04-tools.md) · [05 · Backup](05-backup.md) · [06 · Recover](06-recover.md) · [07 · Mirrors](07-mirrors.md) · [Inventory & variables](inventory.md) · [Roles reference](roles.md)
> **Related:** [Secrets & Vault](../../lab-ecosystem/secrets-and-vault.md) · [Storage & recovery](../../lab-ecosystem/storage-and-recovery.md) · [Naming conventions](../../lab-ecosystem/naming-conventions.md) · [ADR-0001 safe prod-like environment](../../../ADR/0001-safe-prod-like-environment.md)
Ansible is the **imperative half** of the factory: it takes three bare Raspberry Pis (`pi1`, `pi2`, `pi3`) and turns them into a running K3s cluster with Docker, Longhorn storage, Gitea CI runners, CrowdSec, and Vault. OpenTofu (the declarative half) then provisions everything that lives *outside* the cluster — see the [OpenTofu sub-hub](../opentofu/README.md).
@@ -22,7 +22,7 @@ Everything ships as a single Ansible **collection** committed under [`ansible/ar
| `requirements.yml` | [`ansible/requirements.yml`](../../../../ansible/requirements.yml) | External dependencies pulled at install time (see table below). |
| `ansible.cfg` | [`ansible/arcodange/factory/ansible.cfg`](../../../../ansible/arcodange/factory/ansible.cfg) | `collections_path = ~/.ansible/collections` and `scp_if_ssh = True` for the SSH connection plugin. |
| `inventory/` | [`ansible/arcodange/factory/inventory/`](../../../../ansible/arcodange/factory/inventory) | `hosts.yml` + `group_vars/`. Detailed in [Inventory & variables](inventory.md). |
| `playbooks/` | [`ansible/arcodange/factory/playbooks/`](../../../../ansible/arcodange/factory/playbooks) | The numbered pipeline `01..05` plus the `recover/` branch. |
| `playbooks/` | [`ansible/arcodange/factory/playbooks/`](../../../../ansible/arcodange/factory/playbooks) | The numbered pipeline `01..05`, the `recover/` branch, and the on-demand [`07_mirrors.yml`](../../../../ansible/arcodange/factory/playbooks/07_mirrors.yml). |
| `roles/` | [`ansible/arcodange/factory/roles/`](../../../../ansible/arcodange/factory/roles) | Seven reusable roles. Detailed in [Roles reference](roles.md). |
### External dependencies (`requirements.yml`)
@@ -126,10 +126,10 @@ Smaller roles, mostly Gitea/forge plumbing and one-shot helpers. Shared roles li
| Role | Purpose | Key vars / notes | Secrets |
| --- | --- | --- | --- |
| [`gitea_repo`](../../../../ansible/arcodange/factory/roles/gitea_repo) | Ensure a repo exists across Gitea + GitHub + GitLab and add **8h push mirrors** (`sync_on_commit: true`) to GitHub/GitLab. | Creates missing repos on each forge; mirror URLs + namespace IDs in [`vars/main.yml`](../../../../ansible/arcodange/factory/roles/gitea_repo/vars/main.yml). | `github_api_token`, `gitlab_api_token` (from `gitea_vault`). |
| [`gitea_repo`](../../../../ansible/arcodange/factory/roles/gitea_repo) | Ensure a repo exists across Gitea + GitHub + GitLab and add **8h push mirrors** (`sync_on_commit: true`) to GitHub/GitLab. | Creates missing repos on each forge (**private** by default). Owner is per-forge — `gitea_repo_owner` / `github_owner` / `gitlab_owner`, with `github_owner_is_org: false` for a personal account. Each forge can be switched off (`gitea_mirror_github` / `gitea_mirror_gitlab`). See [07 · Mirrors](07-mirrors.md). | `github_api_token`, `gitlab_api_token` (from `gitea_vault`). |
| [`gitea_token`](../../../../ansible/arcodange/factory/roles/gitea_token) | Generate / replace / delete a Gitea access token via `docker exec … gitea admin user generate-access-token`. | Stores the raw token in the fact named by `gitea_token_fact_name`; `gitea_token_replace` / `gitea_token_delete` toggles; scopes default to `write:admin,organization,package,repository,user`. | The minted token itself (a fact, not persisted). |
| [`gitea_secret`](../../../../ansible/arcodange/factory/roles/gitea_secret) | `PUT` a Gitea **Actions secret** at user or org scope. | `gitea_secret_name` / `_value`; `gitea_owner_type` (`user`\|`org`) selects the API path. | `gitea_api_token` (Authorization). |
| [`gitea_sync`](../../../../ansible/arcodange/factory/roles/gitea_sync) | List repos on all **three forges**, diff them, and call `gitea_repo` for the repos missing somewhere. | Computes `repos_incomplete = all common`; loops `gitea_repo` over the gaps. | GitHub/GitLab/Gitea API tokens. |
| [`gitea_sync`](../../../../ansible/arcodange/factory/roles/gitea_sync) | List repos on all **three forges** for **one owner**, diff them, and call `gitea_repo` for the repos missing somewhere. | Computes `repos_incomplete = all common`; loops `gitea_repo` over the gaps. `gitea_sync_owner` + `gitea_sync_owner_is_org` pick the owner (a user is not served at the same API paths). **Not currently invoked by any playbook** — the explicit list in [07 · Mirrors](07-mirrors.md) is what runs. | GitHub/GitLab/Gitea API tokens. |
| [`traefik_certs`](../../../../ansible/arcodange/factory/roles/traefik_certs) | Extract the live **`*.arcodange.lab`** cert from Traefik's `acme.json`. | `kubectl exec` into Traefik → `jq` the LetsEncrypt wildcard cert → `traefik_cert_pem` fact; no-op if already set. | — (reads in-cluster acme.json). |
| [`playwright`](../../../../ansible/arcodange/factory/roles/playwright) | Run a Playwright browser-automation script in Docker. | Builds `playwright:<version>` (default `1.47.0`) from `files/`, runs the script with `playwright_env` injected as `-e`; default script `loginGitea.js`. Used by `hashicorp_vault` for the OIDC app setup. | Script-specific env (e.g. Gitea admin creds). |
| [`deploy_gitea`](../../../../ansible/arcodange/factory/playbooks/setup/roles/deploy_gitea) | Deploy Gitea: template [`app.ini.j2`](../../../../ansible/arcodange/factory/playbooks/setup/roles/deploy_gitea/tasks/main.yml), `docker compose up`, then **health-check `:3000`** until ready. | Compose source is `/home/pi/arcodange/docker_composes/gitea`; admin user `arcodange`. | (consumes the vaulted Gitea compose env). |