diff --git a/.env.example b/.env.example index e8c762f3c..c2de54f6d 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,9 @@ # du service MariaDB sur le réseau Docker externe `frontend` (cf. docker-compose.prod.yml). # Si MariaDB tourne sur l'hôte NAS via Package Center DSM et que le réseau # Docker n'a pas accès direct, utiliser l'IP LAN du NAS au lieu du hostname. +# Mot de passe : le générer en HEXADÉCIMAL (`openssl rand -hex 32`) — une sortie +# base64 peut contenir `/`, qui termine l'identifiant dans une URL et rend +# DATABASE_URL inexploitable. DATABASE_URL=@:3306/kesh> # --- Application --- @@ -324,16 +327,31 @@ RUST_LOG=info # KESH_PRODUCTION_RESET= # ============================================================================= -# Service MariaDB bundled (DEV ONLY — docker-compose.yml + docker-compose.dev.yml) +# Service MariaDB intégré (docker-compose.yml — installation autonome) # ============================================================================= # -# Ces variables sont consommées par les composes dev (`docker-compose.yml` et -# `docker-compose.dev.yml`) qui démarrent un service MariaDB en sidecar pour -# le développement local. **PAS UTILISÉES par docker-compose.prod.yml** — -# en prod, MariaDB est fournie par l'utilisateur (Package Center DSM ou -# autre) et configurée via `DATABASE_URL` ci-dessus. +# Lues par `docker-compose.yml`, qui démarre Kesh ET sa base MariaDB. +# `docker-compose.prod.yml` ne les lit pas : il n'a pas de service MariaDB (la +# base est fournie par l'exploitant, cf. DATABASE_URL ci-dessus). +# `docker-compose.dev.yml` ne les lit pas : ses mots de passe de développement +# sont écrits en dur dans le fichier. +# +# 1. INSTALLATION EXISTANTE (mise à jour) : reprendre la valeur avec laquelle la +# base a été créée, JAMAIS une neuve (manuel d'administration, § Passer à la +# 0.13.0). MariaDB ne lit ces deux valeurs qu'à la CRÉATION de la base : les +# changer ici ensuite ne change pas le mot de passe enregistré, et Kesh +# échoue à se connecter (erreur 1045). Pour changer un mot de passe : +# manuel d'administration, § Changer un mot de passe MariaDB. +# 2. INSTALLATION NEUVE : générer chaque mot de passe par +# openssl rand -hex 32 +# (hexadécimal : un mot de passe base64 peut contenir `/`, qui casse une +# DATABASE_URL), décommenter la ligne et y écrire la valeur. +# +# Sans ces deux lignes, Compose refuse de démarrer en nommant la variable. +# Décommentée SANS valeur (`MARIADB_PASSWORD=`), une ligne fait aussi refuser +# Compose : une valeur vide n'est pas un mot de passe. -MARIADB_ROOT_PASSWORD=kesh_dev_root +#MARIADB_ROOT_PASSWORD= MARIADB_DATABASE=kesh MARIADB_USER=kesh -MARIADB_PASSWORD=kesh_dev +#MARIADB_PASSWORD= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6527032ba..d9c570d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,11 +237,41 @@ jobs: steps: - uses: actions/checkout@v4 # Story 15-11a (#550) — validation des deux compose distribués par Compose - # lui-même (clé inconnue ou en double, interpolation mal formée). Sans - # `.env` : les variables obligatoires non posées ne font qu'avertir. + # lui-même (clé inconnue ou en double, interpolation mal formée). + # Story 15-13a (#551) — `docker-compose.yml` exige ses deux mots de passe + # MariaDB (`${…:?…}`) : validé AVEC des valeurs factices, puis le refus + # SANS elles est exigé (sortie en échec, variable nommée). Chaque + # variable est aussi retirée SEULE, l'autre posée (sinon le refus sur + # l'autre masquerait un défaut revenu) : le retour d'un défaut publié + # pour `MARIADB_ROOT_PASSWORD` fait rougir cette étape. Pour + # `MARIADB_PASSWORD`, non : la `DATABASE_URL` de `kesh-api` l'exige + # aussi, et Compose la nomme encore si le seul service `mariadb` + # retrouve un défaut — c'est le test Rust `configuration_transmise:: + # mariadb` qui rougit alors. `docker-compose.prod.yml` n'a pas de + # variable obligatoire de cette forme : validé sans `.env`. La sortie + # d'erreur va sous `$RUNNER_TEMP`, hors du contexte de build Docker. - name: Validate compose files run: | - docker compose -f docker-compose.yml config -q + refus="$RUNNER_TEMP/refus.txt" + MARIADB_ROOT_PASSWORD=ci MARIADB_PASSWORD=ci \ + docker compose -f docker-compose.yml config -q + if env -u MARIADB_ROOT_PASSWORD -u MARIADB_PASSWORD \ + docker compose -f docker-compose.yml config -q 2> "$refus"; then + echo "docker-compose.yml doit refuser l'absence des mots de passe MariaDB" + exit 1 + fi + cat "$refus" + grep -qE 'MARIADB_(ROOT_)?PASSWORD' "$refus" + for paire in MARIADB_ROOT_PASSWORD:MARIADB_PASSWORD \ + MARIADB_PASSWORD:MARIADB_ROOT_PASSWORD; do + absente="${paire%%:*}"; posee="${paire#*:}" + if env -u MARIADB_ROOT_PASSWORD -u MARIADB_PASSWORD "$posee=ci" \ + docker compose -f docker-compose.yml config -q 2> "$refus"; then + echo "docker-compose.yml doit refuser l'absence de $absente" + exit 1 + fi + grep -q "required variable $absente is missing" "$refus" + done docker compose -f docker-compose.prod.yml config -q - uses: docker/setup-buildx-action@v3 - name: Build image (no push) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b9f23154..d1ad0ce85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **Clôture et création d'un exercice : deux refus neufs pour une intégration par clé d'API ([#543](https://github.com/guycorbaz/kesh/issues/543)).** `POST /api/v1/fiscal-years/{id}/close` rend **`409 EARLIER_FISCAL_YEAR_OPEN`** (nouveau) tant qu'un exercice antérieur est ouvert — `details.fiscalYearId` / `fiscalYearName` nomment le plus ancien, à clôturer d'abord ; une intégration qui clôturait hors d'ordre reçoit désormais ce refus (la clôture est ouverte aux clés, rôle Comptable et plus). `POST /api/v1/fiscal-years` rend **`400 LATER_FISCAL_YEAR_CLOSED`** si un exercice postérieur à la date de début demandée est clôturé, avec un message propre à la création. Les deux routes rejouent un interblocage transitoire. Détail : `docs/api-external.md`. +### Retiré + +- **`init-demo.sh`** ([#551](https://github.com/guycorbaz/kesh/issues/551)). Ce script visait par défaut le conteneur MariaDB de `docker-compose.yml` avec l'ancien mot de passe publié, et ses suppressions successives, sans transaction ni confirmation, auraient vidé une partie d'une base peuplée. La démonstration se sème depuis l'écran d'accueil de Kesh. + ### Corrigé - **Les exercices se clôturent dans l'ordre : un bilan clos ne change plus en silence ([#543](https://github.com/guycorbaz/kesh/issues/543)).** Le bilan est cumulatif depuis l'origine : toute écriture d'un exercice figure dans le bilan des suivants. Or on pouvait clôturer un exercice en laissant ouvert un exercice antérieur — ou créer un exercice antérieur à un exercice clôturé —, puis écrire dans cet exercice ouvert : le bilan clos changeait sans que rien ne le signale. **La clôture est désormais refusée tant qu'un exercice antérieur est ouvert** : à l'écran, le bouton **Clôturer** est désactivé et son infobulle nomme l'exercice à clôturer d'abord. **La création d'un exercice est refusée** si un exercice postérieur est déjà clôturé. Avec la réouverture, qui allait déjà du plus récent vers l'ancien, aucun exercice ouvert ne peut plus précéder un exercice clôturé **à partir d'une installation saine**. Le refus de modifier ou de supprimer une écriture sous un exercice postérieur clos dit maintenant comment s'en sortir — rouvrir les exercices clôturés **en commençant par le plus récent**, et non celui qu'il nomme, que la réouverture refuserait. ⚠️ Une installation mise à jour ou une sauvegarde restaurée peut porter cet état, hérité d'une version antérieure. **Plus rien ne s'enregistre alors dans l'exercice ouvert concerné** : ni saisie, ni validation, règlement, solde ou dévalidation d'une facture, ni avoir, facture fournisseur, paiement, lot de paiement, soldes de départ ou rapprochement — chacun de ces gestes changerait le bilan clos ; le refus nomme l'exercice clôturé. Une contre-passation — et donc toute annulation, qui en porte une — est datée **du jour** : elle reste possible, et n'est refusée que si l'exercice du jour est lui-même suivi d'un exercice clôturé. **L'écran *Exercices comptables* signale l'état** par un bandeau, visible de tous les rôles, qui dit comment rétablir l'ordre : clôturer l'exercice ouvert le plus ancien si ses comptes sont arrêtés, puis les suivants ; sinon, faire rouvrir les exercices clôturés **en commençant par le plus récent**. L'import accepte toujours une telle sauvegarde. ⚠️ **Limites** : l'écran des soldes de départ et les annulations de règlement ou de rapprochement annoncent le geste possible et le refus ne vient qu'au clic ([#568](https://github.com/guycorbaz/kesh/issues/568)). *(API : `400 LATER_FISCAL_YEAR_CLOSED` sur toute route qui écrit au journal, `details.fiscalYearId` / `fiscalYearName` ; dans `failed[]` de `POST /reconciliation/accept`, sans bloquer le reste du lot. Le message de ce refus couvre désormais la saisie.)* @@ -55,13 +59,17 @@ Le contenu est rédigé en français à destination des **fiduciaires, PME, ind - **La configuration posée dans `.env` atteint enfin Kesh ([#550](https://github.com/guycorbaz/kesh/issues/550)).** Les deux compose fournis ne transmettaient au conteneur que les variables listées sous `environment:` — Docker Compose lit `.env` pour remplir le compose, il ne le transmet pas. Seize variables que `.env.example` proposait de régler restaient donc **sans effet, sans aucun message** : les e-mails sortants (`KESH_SMTP_*`, `KESH_PUBLIC_BASE_URL`), le mot de passe oublié par e-mail, la sortie de la démonstration, la langue, la longueur minimale des mots de passe, les plafonds d'import et de sauvegarde, et les cookies `Secure` avec `docker-compose.prod.yml`. Elles sont désormais toutes transmises, et un test empêche qu'un compose en oublie une. Le message de démarrage qui renvoyait à une variable inexistante (`KESH_ADMIN_` + `RESET`) nomme le vrai recours (`KESH_ADMIN_USERNAME` / `KESH_ADMIN_PASSWORD`). `docker-compose.yml` s'installe seul : il désigne l'image publiée au lieu d'exiger les sources. Un `.env` sans `KESH_ADMIN_PASSWORD` ne fait plus refuser le démarrage avec `docker-compose.yml`, ni avertir Compose avec `docker-compose.prod.yml` : l'onboarding `/setup` recommandé par le manuel fonctionne, et le retrait des variables après un break-glass aussi. `KESH_JWT_SECRET` reste obligatoire. **Inchangé** : avec `docker-compose.prod.yml`, les montages restent `./documents`, `./inbox`, `./log`, et les `KESH_*_HOST_DIR` y sont sans effet ([#558](https://github.com/guycorbaz/kesh/issues/558)). - ⚠️ **Action requise sur une installation existante.** La nouvelle image seule ne corrige rien : le défaut est dans le fichier compose que vous détenez, et le compose de la 0.13.0 s'emploie avec l'image 0.13.0. **Re-téléchargez le compose** et reportez-y vos seules adaptations locales (port, réseau, **chemins des montages** — un montage modifié à la main et non reporté revient à `./documents`, et justificatifs et PDF paraissent disparus : ils sont toujours dans l'ancien dossier), puis **`docker compose pull`** et **`docker compose up -d`**. Pour qui garde son fichier, le manuel d'administration décrit les **deux gestes** (§ *Passer à la 0.13.0*). **Relisez votre `.env` avant de mettre à jour** : des lignes jusqu'ici sans effet le deviennent. Kesh **refuse de démarrer** si `KESH_SMTP_TLS` ou `KESH_FEATURE_FORGOT_PASSWORD` portent une valeur autre que `true`/`false`/`1`/`0`, si `KESH_COOKIE_SECURE` en porte une avec `docker-compose.prod.yml`, si le mot de passe oublié est activé avec un SMTP incomplet ou mal formé, ou si, activé, le mailer SMTP ne se construit pas, ou encore si `KESH_JWT_SECRET` ou `KESH_ADMIN_PASSWORD` est resté à un placeholder (cf. **Sécurité**) — le message nomme la variable (`docker compose logs kesh-api`). Sans refus : `KESH_PRODUCTION_RESET` réautorise la réinitialisation **d'une installation restée en démonstration** ; les quatre `KESH_SMTP_HOST`/`_USER`/`_PASSWORD`/`_FROM` posées activent l'envoi de factures, et avec `KESH_FEATURE_FORGOT_PASSWORD=true` la réinitialisation du mot de passe par e-mail ; `KESH_COOKIE_SECURE` agit enfin avec `docker-compose.prod.yml`, de même que `KESH_ADMIN_BACKUP_DIR`, `KESH_LANG`… ; une ligne `KESH_LOG_FILE_PATH=` vide désactive désormais le journal fichier. Une valeur qui contient `$` se met entre apostrophes simples (`KESH_SMTP_PASSWORD='pa$word'`), sinon elle est tronquée. Une variable que `.env` ne pose pas, ou laisse vide, prend son défaut sans avertissement — la sauvegarde pré-import reste dans `/tmp`. Un `.env` copié du gabarit sans modification ne change rien d'autre que le refus du placeholder du secret JWT. + ⚠️ **Action requise sur une installation existante.** La nouvelle image seule ne corrige rien : le défaut est dans le fichier compose que vous détenez, et le compose de la 0.13.0 s'emploie avec l'image 0.13.0. **Re-téléchargez le compose** et reportez-y vos seules adaptations locales (port, réseau, **chemins des montages** — un montage modifié à la main et non reporté revient à `./documents`, et justificatifs et PDF paraissent disparus : ils sont toujours dans l'ancien dossier), puis **`docker compose pull`** et **`docker compose up -d`**. Pour qui garde son fichier, le manuel d'administration décrit les gestes, fichier par fichier (§ *Passer à la 0.13.0*). **Relisez votre `.env` avant de mettre à jour** : des lignes jusqu'ici sans effet le deviennent. Avec `docker-compose.yml`, c'est d'abord **Compose** qui refuse, au terminal et avant tout démarrage, si `MARIADB_ROOT_PASSWORD` ou `MARIADB_PASSWORD` manque à `.env` (cf. **Sécurité**, [#551](https://github.com/guycorbaz/kesh/issues/551)). Kesh **refuse de démarrer** si `KESH_SMTP_TLS` ou `KESH_FEATURE_FORGOT_PASSWORD` portent une valeur autre que `true`/`false`/`1`/`0`, si `KESH_COOKIE_SECURE` en porte une avec `docker-compose.prod.yml`, si le mot de passe oublié est activé avec un SMTP incomplet ou mal formé, ou si, activé, le mailer SMTP ne se construit pas, ou encore si `KESH_JWT_SECRET` ou `KESH_ADMIN_PASSWORD` est resté à un placeholder (cf. **Sécurité**) — le message nomme la variable (`docker compose logs kesh-api`). Sans refus : `KESH_PRODUCTION_RESET` réautorise la réinitialisation **d'une installation restée en démonstration** ; les quatre `KESH_SMTP_HOST`/`_USER`/`_PASSWORD`/`_FROM` posées activent l'envoi de factures, et avec `KESH_FEATURE_FORGOT_PASSWORD=true` la réinitialisation du mot de passe par e-mail ; `KESH_COOKIE_SECURE` agit enfin avec `docker-compose.prod.yml`, de même que `KESH_ADMIN_BACKUP_DIR`, `KESH_LANG`… ; une ligne `KESH_LOG_FILE_PATH=` vide désactive désormais le journal fichier. Une valeur qui contient `$` se met entre apostrophes simples (`KESH_SMTP_PASSWORD='pa$word'`), sinon elle est tronquée. Une variable que `.env` ne pose pas, ou laisse vide, prend son défaut sans avertissement — hormis, avec `docker-compose.yml`, `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD`, désormais obligatoires — ; la sauvegarde pré-import reste dans `/tmp`. Un `.env` copié du gabarit sans modification ne change rien d'autre que le refus du placeholder du secret JWT et, avec `docker-compose.yml`, le sort des mots de passe MariaDB : copié du gabarit 0.13.0, où ils sont commentés, il fait refuser Compose ; copié d'un gabarit antérieur, qui posait `kesh_dev`, il fait émettre à Kesh l'avertissement « mot de passe publié ». **Exploitant de `docker-compose.yml` qui construit depuis un clone** : après `git pull`, `docker compose up -d` **tire** `gcorbaz/kesh:latest` au lieu de construire les sources — `docker compose up -d --build` pour construire, et `docker compose pull kesh-api` pour revenir à l'image publiée après un build local qui l'a étiquetée `latest`. ### Sécurité -- **Le secret JWT et le mot de passe admin laissés au placeholder du gabarit, ou à un gabarit entre chevrons du manuel, sont refusés au démarrage ([#557](https://github.com/guycorbaz/kesh/issues/557)).** Jusqu'ici, `KESH_JWT_SECRET=` recopié tel quel de `.env.example` était **accepté** : les jetons de session étaient signés avec un secret publié dans le dépôt. De même, `KESH_ADMIN_PASSWORD=` décommenté tel quel, ou `KESH_ADMIN_PASSWORD=` recopié du manuel, créait sur une base vide un administrateur au mot de passe publié. Toute valeur qui contient `GENERATE_ME` (sans égard à la casse) ou qui est de la forme `<…>` est désormais refusée pour ces deux variables, avant le contrôle de longueur. **Action pour une installation existante**, avant la mise à jour, le compose final en place : la vérification du manuel d'administration (§ *Passer à la 0.13.0*), `sudo docker compose config | grep -iE 'KESH_(JWT_SECRET|ADMIN_PASSWORD): .? *(<|.*generate_me)'`, doit rester muette. Si elle montre `KESH_JWT_SECRET`, remplacez la valeur par la sortie de `openssl rand -hex 32` (toutes les sessions ouvertes sont invalidées, chacun se reconnecte) ; si elle montre `KESH_ADMIN_PASSWORD`, choisissez un vrai mot de passe ou recommentez la ligne — **et**, si l'administrateur a été créé avec ce placeholder, **changez son mot de passe** dans Kesh (le refus au démarrage ne change pas un compte déjà créé). Puis `docker compose up -d`. Sans cette action, Kesh 0.13.0 refuse de démarrer, avec un message qui nomme la variable. +- **Le secret JWT et le mot de passe admin laissés au placeholder du gabarit, ou à un gabarit entre chevrons du manuel, sont refusés au démarrage ([#557](https://github.com/guycorbaz/kesh/issues/557)).** Jusqu'ici, `KESH_JWT_SECRET=` recopié tel quel de `.env.example` était **accepté** : les jetons de session étaient signés avec un secret publié dans le dépôt. De même, `KESH_ADMIN_PASSWORD=` décommenté tel quel, ou `KESH_ADMIN_PASSWORD=` recopié du manuel, créait sur une base vide un administrateur au mot de passe publié. Toute valeur qui contient `GENERATE_ME` (sans égard à la casse) ou qui est de la forme `<…>` est désormais refusée pour ces deux variables, avant le contrôle de longueur. **Action pour une installation existante**, avant la mise à jour, le compose final en place et, avec `docker-compose.yml`, les deux mots de passe MariaDB posés (entrée suivante) : `sudo docker compose config -q && echo 'compose lisible'` doit afficher `compose lisible` — si Compose refuse le fichier, la vérification qui suit ne reçoit rien et reste muette comme un succès —, puis la vérification du manuel d'administration (§ *Passer à la 0.13.0*), `sudo docker compose config | grep -iE 'KESH_(JWT_SECRET|ADMIN_PASSWORD): .? *(<|.*generate_me)'`, doit rester muette. Si elle montre `KESH_JWT_SECRET`, remplacez la valeur par la sortie de `openssl rand -hex 32` (toutes les sessions ouvertes sont invalidées, chacun se reconnecte) ; si elle montre `KESH_ADMIN_PASSWORD`, choisissez un vrai mot de passe ou recommentez la ligne — **et**, si l'administrateur a été créé avec ce placeholder, **changez son mot de passe** dans Kesh (le refus au démarrage ne change pas un compte déjà créé). Puis `docker compose up -d`. Sans cette action, Kesh 0.13.0 refuse de démarrer, avec un message qui nomme la variable. + +- **MariaDB n'est plus joignable depuis le réseau, et n'a plus de mots de passe publiés ([#551](https://github.com/guycorbaz/kesh/issues/551)).** `docker-compose.yml` publiait le port 3306 de MariaDB sur **toutes** les interfaces de l'hôte, avec des mots de passe par défaut écrits dans le dépôt (`kesh_dev_root` pour root, `kesh_dev` pour Kesh) — et un port publié par Docker **contourne le pare-feu de l'hôte** (UFW) : le pare-feu recommandé par le manuel ne le fermait pas. Le port n'est plus publié (Kesh passe par le réseau interne de Docker) ; `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD` n'ont plus de défaut : sans eux, Compose refuse de démarrer en nommant la variable ; `.env.example` ne les pose plus (lignes commentées). Kesh **avertit au démarrage** quand le mot de passe de `DATABASE_URL` est un mot de passe publié (`kesh_dev`, `kesh_dev_root`) ou un gabarit `<…>` recopié du manuel, et un refus d'identifiant de MariaDB (erreur 1045) est désormais suivi d'un **indice**. Le manuel fait générer le mot de passe MariaDB de l'installation Synology en hexadécimal (`openssl rand -hex 32`) : la sortie base64 qu'il prescrivait contenait une fois sur deux un `/`, qui rendait la `DATABASE_URL` inexploitable. + + ⚠️ **Action requise** (`docker-compose.yml`) : re-téléchargez le compose. Sans les deux variables, Compose refuse de démarrer en les nommant — sur une base déjà créée, écrivez-y **les valeurs avec lesquelles elle a été créée, jamais des neuves** (pour qui n'en avait jamais posé : les anciens défauts ; pour qui avait recopié un gabarit `<…>` : cette chaîne telle quelle), car MariaDB ne relit pas ces variables après la création de la base ; puis **changez ces mots de passe** selon le manuel d'administration (§ *Changer un mot de passe MariaDB* : `ALTER USER` d'abord, `.env` ensuite). Seule une **installation neuve** (aucune base encore créée) prend des valeurs neuves (`openssl rand -hex 32`). Un outil de l'hôte qui se connectait au port 3306 passe désormais par `docker compose exec mariadb`. **Limite** : Kesh ne voit pas le mot de passe **root** — un `kesh_dev_root` recopié d'un ancien `.env`, ou un root resté au gabarit `<…>`, ne déclenche aucun avertissement ; seuls le contrôle de fin de mise à jour du manuel (connexion à la base refusée avec l'ancien mot de passe) et la relecture des chevrons de `.env` le montrent — et des chevrons vus à la relecture **ne se remplacent pas dans `.env`** : la base garde cette chaîne, on la change par `ALTER USER` d'abord. Le script de sauvegarde du manuel lit désormais le mot de passe root dans le conteneur (`sh -c`) et vise le service `mariadb` (il nommait un service `db` inexistant) : lancez-le une fois à la main après la mise à jour. --- diff --git a/Cargo.lock b/Cargo.lock index 917a500ec..78e83d8bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1986,6 +1986,7 @@ dependencies = [ "kesh-seed", "lettre", "pdfium-render", + "percent-encoding", "proc-macro2", "quote", "reqwest", @@ -2008,6 +2009,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "unicode-normalization", + "url", "uuid", "yaml-rust2", "zip", diff --git a/DOCKER_START.md b/DOCKER_START.md index c97b2d0d5..892b2be73 100644 --- a/DOCKER_START.md +++ b/DOCKER_START.md @@ -3,23 +3,45 @@ ## Prérequis - Docker & Docker Compose installés -- Port 80 et 3306 disponibles sur la machine hôte +- Port 80 disponible sur la machine hôte (MariaDB n'est pas publiée : Kesh + l'atteint par le réseau interne de Docker) ## Démarrage rapide ### 1. Préparer `.env` (obligatoire) -Sans `.env`, Kesh refuse de démarrer : le secret JWT par défaut de -`docker-compose.yml` (`change-me…`) est refusé à dessein. +Sans `.env`, c'est d'abord **Compose** qui refuse, au terminal, en nommant +`MARIADB_ROOT_PASSWORD` ou `MARIADB_PASSWORD` (une seule à la fois, pas toujours +la même) : depuis la 0.13.0, `docker-compose.yml` n'a plus de mots de passe +MariaDB par défaut. Ensuite seulement, Kesh refuse le secret JWT par défaut de +`docker-compose.yml` (`change-me…`), à dessein. + +⚠️ **Base déjà créée** (mise à jour d'une installation existante, ou volume +`kesh-mariadb-data` déjà présent) : **ne générez pas** de mots de passe MariaDB. +Écrivez dans `.env` **la valeur avec laquelle la base a été créée, jamais une +neuve** — si vous n'en aviez jamais posé, les anciens défauts +`MARIADB_ROOT_PASSWORD=kesh_dev_root` et `MARIADB_PASSWORD=kesh_dev` ; si vous +aviez recopié une chaîne gabarit `<…>`, cette chaîne telle quelle. Puis changez +ces mots de passe par la procédure du manuel d'administration (§ *Changer un mot +de passe MariaDB* : `ALTER USER` d'abord, `.env` ensuite ; § *Passer à la +0.13.0*). + +**Installation neuve** (aucune base encore créée) : ```bash cp .env.example .env -# Remplacer dans .env la valeur de KESH_JWT_SECRET par la sortie de : +# Dans .env, remplacer la valeur de KESH_JWT_SECRET, puis décommenter +# MARIADB_ROOT_PASSWORD et MARIADB_PASSWORD et leur donner une valeur — +# chacune la sortie d'une commande (installation neuve seulement) : openssl rand -hex 32 ``` Le placeholder `` recopié tel quel est refusé au démarrage -(depuis la 0.13.0). +(depuis la 0.13.0). Les deux mots de passe MariaDB ne sont lus qu'à la +**création** de la base : les changer ensuite dans `.env` ne change pas le mot +de passe enregistré — Kesh échoue alors en 1045, et pour root rien ne le +signale hormis la sauvegarde. Hexadécimal : une sortie base64 peut contenir +`/`, qui casse une `DATABASE_URL`. ### 2. Lancer les containers @@ -99,9 +121,12 @@ docker compose up -d --build kesh-api ### Accéder à la base de données ```bash -docker compose exec mariadb mysql -u kesh -pkesh_dev -D kesh +docker compose exec mariadb mariadb -u kesh -p -D kesh ``` +Le mot de passe (`MARIADB_PASSWORD` de `.env`) est demandé. Le port 3306 n'est +pas publié sur l'hôte : un port publié par Docker contourne le pare-feu (UFW). + ### Voir les volumes créés ```bash @@ -131,10 +156,33 @@ Cf. `.env.example` section "Conflit port 80" et le manuel admin section (macvlan IP dédiée, dev `cargo run` natif sur Linux non-root, etc.). Puis appliquez : `docker compose up -d`. -### Base de données ne s'initialise pas +### Base de données ne démarre pas, ou Kesh ne s'y connecte pas + +Diagnostic d'abord (`logs` fonctionne même quand Compose refuse `up` faute de +mots de passe — mesuré avec Docker Compose 2.40 ; si votre version refuse +aussi `logs`, lisez l'erreur affichée au terminal par `up`) : +```bash +docker compose logs kesh-api +docker compose logs mariadb +``` + +- **Erreur 1045** (« Access denied ») ou l'**indice** de Kesh sur + `MARIADB_PASSWORD` : le mot de passe de `.env` n'est pas celui avec lequel la + base a été créée — MariaDB ne relit pas `.env`. Revenir à la valeur + d'origine (manuel d'administration, § *Passer à la 0.13.0*), puis changer le + mot de passe par la procédure du manuel (`ALTER USER` d'abord). +- **Compose refuse en nommant une variable** (`required variable … is missing + a value`) : la poser dans `.env` — sur une **base déjà créée**, la valeur + d'origine (`kesh_dev_root` / `kesh_dev` si vous n'en aviez jamais posé), + jamais une neuve ; une valeur neuve seulement sur une installation neuve. + Compose ne nomme qu'une variable à la fois : posez les deux. + +⛔ `docker compose down -v` **supprime le volume de la base, définitivement** — +les livres avec. Ne l'employer que sur une installation **sans données à +conserver**, pour repartir d'une base vide : ```bash docker compose down -v -docker compose up --build +docker compose up -d ``` ## Notes @@ -143,4 +191,6 @@ docker compose up --build - Runtime: Debian Bookworm Slim - Rust: 1.85 (build stage uniquement) - Node.js: 22 (build stage uniquement) -- Les données sont persistées dans le volume `mariadb_data` +- Les données sont persistées dans le volume `kesh-mariadb-data` du compose, + que `docker volume ls` affiche `_kesh-mariadb-data` (préfixe du + projet Compose, par défaut le nom du répertoire) diff --git a/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md b/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md new file mode 100644 index 000000000..66fd687d8 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md @@ -0,0 +1,183 @@ +# Story 15.13 : MariaDB et sauvegarde avant import — fiche index, découpée en 15-13a et 15-13b + +Status: split + + + +**Issues** : #551 (fermée par la **15-13a**), #552 et #576 (fermées par la **15-13b**) ; `refs #558` +(15-13b), `refs #577` (15-13a) ; #575 et #554 citées, hors périmètre. + +## Découpage + +**La couture** : #551 (MariaDB : port, mots de passe, avertissement, indice 1045 — tout ce qui touche la +base et son accès) d'un côté ; #552 et #576 (la sauvegarde pré-import : son dossier, son mode, le message de +son échec) de l'autre. #576 ne se sépare pas de #552 : elle naît du nouveau défaut `/data/backup`, qui fait +de l'échec d'écriture le cas nominal hors Docker (C-15-13-9). Aucune des deux fiches n'appelle ce que +l'autre ajoute ; elles touchent les **mêmes fichiers** (`docker-compose.yml`, `.env.example`, `config.rs`, +`configuration_transmise.rs`, `admin-manual.tex` § *Passer à la 0.13.0*, `DOCKER_START.md`, +`CHANGELOG.md` `[0.13.0]`) à des endroits distincts. + +| fiche | contenu | issues | modules de code | dépend de | +|---|---|---|---|---| +| **15-13a** — `15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md` | port de MariaDB non publié ; `${MARIADB_*_PASSWORD:?…}` (refus nommé de Compose, vérifié en CI dans les deux sens) ; `.env.example` sans `kesh_dev` ; avertissement au démarrage (mot de passe publié **ou gabarit du manuel**, F-P3-3) ; indice sur le 1045 ; `configuration_transmise.rs` (service `mariadb`, scission de l'interpolation, `VALEURS_COMPOSEES`) ; manuel (ports, étape `.env`, hex, paragraphe MariaDB de la mise à jour, changement de mot de passe, `exec mariadb`, dépannage), brochure, `DOCKER_START.md`, retrait d'`init-demo.sh`, `docs/ci.md` ; CHANGELOG **Sécurité** #551 et **Retiré** | **closes #551**, refs #577 | `config`, `main` (2) | 15-11a, 15-11b (mergées) | +| **15-13b** — `15-13b-sauvegarde-avant-import-persistante.md` | défaut `/data/backup` (`config::env_nonempty` inchangé), montage fixe `./backup` des deux compose, fichier `0600` sans écrasement, dossier `0700` ; variante `AdminPreImportBackupFailed`, `avant_sauvegarde` et le tri des 17 sites `AdminFullImportFailed` de `routes/admin.rs` ; quatre locales ; recettes hors Docker (AC 16, élargi aux modes `cargo run` documentés) ; `.gitignore` (motifs **ancrés**, R3-1) et `.dockerignore` ; rapatriement pour restaurer ; manuel (sauvegarde) ; CHANGELOG **Corrigé** #552, #576 et **Sécurité** (dossiers ignorés) | **closes #552**, **closes #576**, refs #558 | `config`, `routes/admin`, `errors`, catalogues `kesh-i18n` (4) | 15-11a, 15-11b (mergées) | + +**Ordre suggéré** : 15-13a, puis 15-13b — #551 est P2 (sécurité), #552 et #576 P3. L'ordre inverse est +possible : la seule conséquence est le **recompte des gestes** du paragraphe « Pour qui garde son fichier +compose » (`admin-manual.tex:1793`) et de `CHANGELOG.md:46`, que fait la fiche mergée en second. Nombres, +par fichier (`docker-compose.yml` / `docker-compose.prod.yml`) : aujourd'hui 2 / 2 ; 15-13a seule 4 / 2 ; +15-13b seule 3 / 3 ; les deux 5 / 3. + +**Si une seule est mergée au tag v0.13.0** — chaque fiche ne réécrit que ce que **son** changement rend +faux, si bien que l'autre partie du CHANGELOG et du manuel reste vraie : +- **15-13a seule** : CHANGELOG `[0.13.0]` avec l'entrée **Sécurité** #551, la rubrique **Retiré** + (`init-demo.sh`), quatre phrases de `:46` réécrites et `:52` corrigée ; la phrase « la sauvegarde + pré-import reste dans `/tmp` » reste (vraie). Manuel : parties MariaDB ; les « défaut `/tmp` » + (`:757`, `:1664`, `:1699`) restent (vrais). +- **15-13b seule** : CHANGELOG avec les entrées **Corrigé** #552 et #576, la **Sécurité** « dossiers + montés ignorés », la phrase `/tmp` et le décompte des gestes de `:46` réécrits ; les trois autres phrases + de `:46` restent (vraies tant que les mots de passe ont un défaut). Manuel : parties sauvegarde. #551 + reste ouverte, et la release ne peut pas se dire « MariaDB fermée ». + +### Recompte aux deux bornes + +Borne basse : la fiche unique au commit **8a9bcd27** (comptes vérifiés par la lentille R de la P3). +Borne haute : les deux fiches filles de ce commit, recomptées par `grep` (`'^[0-9]+\. \*\*'` pour les AC, +`'^- \[ \] \*\*T'` pour les tâches, `grep -oE '\*\*M[0-9]+\*\*' | sort -u` pour les mutations, `'^| '` pour +les tableaux). + +| Objet | Fiche unique (8a9bcd27) | 15-13a | 15-13b | Écart expliqué | +|---|---|---|---|---| +| AC (numéros) | 16 | 11 (1–6, 10–14) | 10 (7–16) | 5 numéros partagés (10–14), sous-points répartis : 11 + 10 − 5 = 16, aucun perdu | +| Tâches | 11 (T0–T10) | 11 | 9 (sans T4, T7) | T4 (CI) et T7 (recette MariaDB) propres à la 15-13a ; les autres scindées | +| Lignes de test | 19 (18 Rust + 1 CI) | 9 (8 + CI) | 11 | 9 + 11 = 20 : **+1**, la ligne **3a** (`valeurs`, ci-dessous) | +| Fonctions de test Rust | 19 (15 neuves, 4 complétées) | 8 (7 neuves, 1 complétée) | 12 (8 neuves, 4 complétées) | 20 = 19 **+ `valeurs`**, modifiée par le resserrement de `VALEURS_COMPOSEES` mais jamais comptée (constat du remédiateur : M5 rougit `valeurs`, non `transmission` ni le test 1) | +| Mutations | 40 (M1–M40) | 22 | 20 | 42 = 40 + **M41** (R3-1, 15-13b) + **M42** (F-P3-3, 15-13a) ; aucune en double (`sort -u` sur l'union → 42, de M1 à M42 sans trou) | +| Tableau des cas | 11 lignes | 10 | 3 | 13 = 11 + 2 lignes scindées (« compose gardé », « image ancienne ») | +| Inventaire | 57 lignes | 47 | 36 | totaux recomptés aux deux bornes (`grep -c '^| '` sur chaque tableau, en-tête exclu) ; la ventilation ligne à ligne écrite ici au découpage (« 29 passées entières… → 36 + 28 ») **ne se refaisait pas** (R4-7 de la validation P4 de la 15-13a : 29 + 6 ≠ 36) et est retirée. Depuis la validation P4 : 48 (15-13a) et 41 (15-13b), détail au Change Log de chaque fille | +| Angles morts | 12 | 4 | 8 | aucun perdu ; celui du gabarit réécrit (root seul, tracé par issue) | +| Modules de code | 5 | 2 | 4 | `config` dans les deux ; union = 5 | + +## Change Log + +- 2026-10-09 — Spécification (agent de spécification, Opus 5.5, en autonomie). 14 AC, 11 tâches (T0–T10), + 13 lignes de test (13 fonctions Rust dont 10 neuves, 1 étape CI), 27 mutations ; choix C-15-13-1 à C-15-13-7. Issues à ouvrir signalées à + l'orchestrateur : section Synology du manuel (pré-script et aperçu sans service `mariadb`, nom de volume + faux) ; message `error-admin-full-import-failed` faux quand l'écriture de la sauvegarde échoue. + *(Ouvertes depuis : **#575** et **#576**.)* +- 2026-10-09 — **Remédiation de la validation P1** (agent remédiateur, Opus 5.5, en autonomie). Passe P1 : + deux lentilles Opus 5.5 en contexte frais — R (auditeur d'acceptation) **0 CRITICAL / 0 HIGH / 4 MEDIUM / + 12 LOW**, F (adversaire plein périmètre) **0 / 0 / 7 MEDIUM / 8 LOW** ; doublons R-1 = F1, R-3 = F6, + R-2 ≈ F4 — soit **8 MEDIUM distincts** ; 20 LOW, dont 17 distincts (L1 = F8, L2 = F9, L4 = F10). Tous remédiés : + - **#576 absorbée** (F7, décision de l'orchestrateur, C-15-13-9) : AC 15 (variante + `AdminPreImportBackupFailed`, `avant_sauvegarde` à l'appel, quatre locales et repli Rust) et AC 16 + (recettes `cargo run` du dépôt — `CLAUDE.md`, une ligne ; `docs/testing.md`) ; la PR porte + `closes #576`. + - **Sauvegarde `0600` gardée, rapatriement au manuel** (R-2/F4, décision de l'orchestrateur, C-15-13-8) : + AC 9, 11 e, T0, ligne « restaurer après un import raté », angle mort ACL Synology. + - **Contrôle de fin de mise à jour par connexion à la base** (F3, décision de l'orchestrateur, + C-15-13-10) : AC 11 f, T7 ; lignes « root neuf sans `ALTER USER` » et « gabarit littéral » (R-4) au + tableau des cas ; « aucun cas ne casse en silence » réécrit. + - **YAML du message `:?`** (R-1/F1) et **scission `:?`/`?`** (F2, M28-M29) : AC 2 c, 10 a, 10 d, + C-15-13-12. + - **Inventaire recompté** (R-3/F6) : grep du T9 rejoué au `HEAD` (83 fichiers, chacun trié), plus + `MARIADB_` et `cargo run -p kesh-api` ; `init-demo.sh` résolu (AC 12 b, C-15-13-11) ; numéros de + ligne corrigés (`docker-compose.dev.yml:15`, `:54`, `:56-59` ; `ci.yml:55` ; `release.yml:26` ; + `admin-manual.tex:1761-1776`, `:1746`). + - **Propagation** (F5) : AC 13 c étendu aux quatre phrases de `CHANGELOG.md:46` ; AC 11 f à l'encadré + `:1761-1776` (refus de Compose affiché au terminal). + - LOW : `percent-encoding` (L1/F8) ; deux fonctions pour l'indice 1045 et repli `(using password` + (L2/F9) ; `write_backup_file` nommée (L3) ; hôte du test 8 mesuré au T0 (L4/F10) ; contrôles PDF + rendus discriminants, vérifiés rouges sur le PDF aplati d'avant (L5) ; #575/#576 cités (L7) ; recettes + de dev (L8, avec F7) ; nom de volume `_kesh-mariadb-data` (L9) ; limite root au CHANGELOG + (L10) ; `VALEURS_COMPOSEES` resserré (L11) ; textes « trois » (L12) ; fichier partiel supprimé (F11) ; + commentaire `admin.rs:475-477` (F12) ; Compose nomme une variable à la fois (F13) ; ordre existant → + neuf (F14) ; jetons fantômes au manuel et pré-script Synology à joindre à #575 (F15) ; `cd` du script + de sauvegarde (AC 11 h). + - **Recompte** (depuis la fiche) : **16 AC** (14 → 16), **11 tâches** (T0–T10), **18 lignes de test** + (13 → 18) portant **18 fonctions Rust** (14 neuves, 4 existantes complétées) dans un fichier neuf et cinq + existants, plus 1 étape CI ; **37 mutations** (M1–M37, 27 → 37). **Modules** : 5 (`config`, `main`, + `routes/admin`, `errors`, catalogues `kesh-i18n`) — seuil de découpage atteint, non franchi. + - **Signal de découpage** (D5) : la passe P1 est la première, aucune comparaison de sévérité n'est + possible ; le décompte de modules passe de 3 à 5 par l'absorption de #576 — déclaré au Project Lead + via l'orchestrateur. +- 2026-10-09 — **Remédiation de la validation P2** (agent remédiateur, Opus 5.5, en autonomie). Passe P2 : + deux lentilles **Sonnet** en contexte frais — R (regression hunter) **0 CRITICAL / 0 HIGH / 3 MEDIUM / + 7 LOW**, F (adversaire plein périmètre) **0 / 0 / 4 MEDIUM / 8 LOW** ; doublon R2-3 = F4 — soit **6 MEDIUM + distincts** ; 15 LOW, dont 14 distincts (le cas `umask 077` de R2-10 = F12). **Trend** : P1 (Opus 5.5) + 8 MEDIUM distincts → P2 (Sonnet) 6 MEDIUM distincts ; 0 CRITICAL/HIGH aux deux passes. Tous remédiés : + - **`init-demo.sh` retiré** (F2, décision de l'orchestrateur, C-15-13-13, qui remplace C-15-13-11) : + redondant avec la démonstration semée par l'application (`seed-demo` → `kesh_seed`), appelé par + personne (`git grep`) ; AC 12 b réécrit, T6/T7, CHANGELOG « Retiré », inventaire. + - **Test 16** (R2-1) : l'interdit est la **parenthèse entière** de l'ancienne promesse, par locale, avec + assertion de montage (l'interdit figure dans l'ancien texte de sa locale) ; vérifié par `grep -cF` + qu'aucune négation naturelle de l'AC 15 c ne le contient ; AC 15 c dit que la négation est la forme + attendue ; mutation M38. + - **Registres réalignés** (R2-2) : `sprint-status.yaml` (lignes 1 et 359) et la fiche d'epic (`:29-35`) + — 16 AC, 19 fonctions, 40 mutations, 5 modules, C-15-13-1 à 15, `closes #551 #552 #576`, #575/#576. + - **Brochure** (R2-3/F4) : AC 11 m, T5, inventaire ; T9 gagne un grep **par la valeur** du geste de + démarrage (`docker[ -]compose( -f …)? up`) sur `docs`, `website`, `README.md`, `DOCKER_START.md` — + sites triés à l'inventaire (`website/` : aucune commande sur `docker-compose.yml`). + - **Dossiers montés hors du dépôt** (F1, C-15-13-14) : AC 8 e, propagé par le symptôme à `./inbox` et + `./documents` ; test 19 dérivé de `MONTAGES`, mutations M39-M40. + - **`DOCKER_START.md:134-137`** (F3) : AC 12 a-bis — diagnostic du 1045 d'abord, `down -v` réservé à une + installation sans données. + - LOW : numéros de ligne (`admin-manual.tex:1667-1672`, `errors.rs:448-452`, `admin.rs:467-470` ajouté, + C-15-13-5 rectifié `:573` par ajout) (R2-4, R2-6) ; AC 16 c complété (`password-recovery.spec.ts:16`, + `global-setup.ts:34`) (R2-5) ; C-15-13-7 renvoie à C-15-13-9 par ajout (R2-7) ; manuel par **fragment** + `:?`, pas de ligne `DATABASE_URL` entière (76 caractères) (R2-8) ; contrôle des placeholders précédé de + `config -q && echo 'compose lisible'` (R2-9) ; fichier partiel nommé par le `warn!`, angle mort (R2-10) ; + `SELECT CURRENT_USER()`, compte relevé pour `127.0.0.1` et le nom du service, commandes coupées par `\` + (F5) ; `docs/ci.md` « 4 jobs » → 3, le job `e2e` fantôme laissé à une issue (F6, C-15-13-15) ; helper + `lancer_binaire` dans `tests/common/binaire.rs` (F7) ; refus de Compose sur toute sous-commande, mesuré + au T0 (F8) ; portée exacte de l'AC 16 et date de la recette du `CLAUDE.md` (F9) ; motifs PDF à + apostrophe et `--` validés sur le PDF neuf (F10) ; forme du `mariadb-dump` et de la restauration aux + Dev Notes, rejouées au T7 (F11) ; message du test 11 qui nomme l'umask (F12) ; angle mort SELinux. + - **Recompte** (depuis la fiche, `grep`) : **16 AC**, **11 tâches** (T0–T10), **19 lignes de test** (18 + → 19) portant **19 fonctions Rust** (15 neuves, 4 existantes complétées) dans un fichier neuf et cinq + existants, plus 1 étape CI et un module d'aide de test ; **40 mutations** (M1–M40, 37 → 40, aucun trou + ni doublon). **Modules de code** : 5 (`config`, `main`, `routes/admin`, `errors`, catalogues + `kesh-i18n`) — inchangé : la remédiation n'ajoute que des fichiers d'exclusion, de documentation, un + helper de test et un retrait de script ; la convention de décompte est désormais écrite (§ *Règle de + découpage*). + - **Signal de découpage** (D5) : sévérité égale (MEDIUM → MEDIUM). Les six MEDIUM sont **distincts** de + ceux de la P1 ; deux naissent de la remédiation P1 (R2-1 : test 16 ; R2-2 : registres non propagés) et + un en est aggravé (F2 : `init-demo.sh` rendu opérant) ; trois sont des sites d'origine que l'inventaire + avait manqués (brochure, `.gitignore`, `DOCKER_START.md:134`). Aucun défaut ne revient sous une autre + forme : pas de recyclage, seuil de modules non franchi — pas de découpage, signal déclaré au Project + Lead via l'orchestrateur. +- 2026-10-09 — **Validation P3 et découpage** (agent remédiateur, Opus 5.5, en autonomie). Passe P3 : deux + lentilles **Opus 5.5** en contexte frais (prompt `15-13-validate-prompt-p3.md`) — R (regression hunter) + **0 CRITICAL / 0 HIGH / 2 MEDIUM / 8 LOW**, F (adversaire plein périmètre) **0 / 0 / 3 MEDIUM / 8 LOW** ; + aucun doublon MEDIUM — soit **5 MEDIUM distincts** ; 16 LOW, dont 14 distincts (R3-3 = F-P3-4 : #577 ; + F-P3-7 contenu dans R3-6). **Trend** : P1 (Opus 5.5 ×2) 8 MEDIUM distincts → P2 (Sonnet ×2) 6 → P3 (Opus 5.5 + ×2) 5 ; 0 CRITICAL/HIGH aux trois passes. + - **Signal D5 de recyclage, deuxième passe consécutive** : en P2, deux MEDIUM nés de la remédiation P1 + (R2-1, R2-2) ; en P3, trois nés de la remédiation P2 — **R3-1** (correctif de F1 : le test 19 admettait + le motif non ancré que C-15-13-14 écarte, et `backup/` masque `frontend/src/routes/(app)/admin/backup/`, + versionné), **R3-2** (correctif de F2 : le grep de vérification du retrait d'`init-demo.sh` contredit + l'entrée de CHANGELOG exigée), **F-P3-2** (correctif de R2-9 non propagé à `CHANGELOG.md:52`). Selon + l'amendement D5 (rétrospective de l'Epic 25), c'est la forme qui découpe : défauts nés du correctif + précédent. **Décision de l'orchestrateur : découpage** (C-15-13-16) selon la couture #551 / #552-#576. + - **Répartition** : § *Découpage* ci-dessus ; recompte aux deux bornes écrit. Numérotation de la fiche + unique conservée dans les deux filles (AC, tests, mutations, tâches) pour la traçabilité. + - **Remédiation** (détail dans le Change Log de chaque fille) : 15-13a — R3-2, F-P3-1 (partie MariaDB), + F-P3-2, F-P3-3 (C-15-13-17, avertissement du mot de passe applicatif gabarit, M42 ; angle mort root + gabarit → issue à ouvrir par l'orchestrateur), LOW R3-3/F-P3-4, R3-4, R3-5, R3-6 (partie a), R3-7, + R3-8, F-P3-6, F-P3-8, F-P3-9 ; 15-13b — R3-1 (C-15-13-19, M41), F-P3-1 (partie sauvegarde), LOW R3-6 + (partie b)/F-P3-7, R3-9, F-P3-5, F-P3-10, F-P3-11. **R3-10** (`sprint-status.yaml`) : en-tête `(27)` + rétabli dans sa forme d'origine, entrée `(28)` ajoutée. Rectificatifs du registre (C-15-13-13 : `DELETE` + à `:70-75` ; C-15-13-14 : homonyme réel et ancrage ; C-15-13-15 : #577) : C-15-13-20. + - **Constats du remédiateur, hors rapports** : M5 et `VALEURS_COMPOSEES` relèvent du test `valeurs` + (famille (V)), non de `transmission` — ligne 3a de la 15-13a, fonction `valeurs` comptée ; grep + `MARIADB_` (axe non exercé par la lentille R de la P3) rejoué : quatre homonymes (`retry.rs:57`, `:106`, + `reconciliation.rs:1129`, `:1136`) entrés à l'inventaire de la 15-13a. +- 2026-10-09 — **Validation P4 des deux filles** (agent remédiateur, Opus 5.5). Deux lentilles **Sonnet** par + fille : 15-13a **1 MEDIUM** distinct (R4-1 = F1), 15-13b **4 MEDIUM** distincts (R4-2 = F-P4-1) ; 0 + CRITICAL/HIGH. Remédiation dans chaque fille (Change Log) ; choix C-15-13-21 à C-15-13-26. Ici : ligne + « Inventaire » du recompte réduite aux totaux (R4-7). diff --git a/_bmad-output/implementation-artifacts/15-13-validate-prompt-p1.md b/_bmad-output/implementation-artifacts/15-13-validate-prompt-p1.md new file mode 100644 index 000000000..9ec0549e4 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13-validate-prompt-p1.md @@ -0,0 +1,35 @@ +# Prompt — validation P1 de la spec, Story 15-13 + +*Versionné le 2026-10-09. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md`. C'est la première passe : la fiche n'a pas encore été remédiée. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #551, #552 (et #558 pour le contexte). +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + la fiche). Choix C-15-13-1 à 7 ; ce que la 15-11a (compose, `${KESH_X:-}`, refus des gabarits) et la 15-11b (`config::env_nonempty`, test lexical) ont livré. Issues voisines ouvertes à la spécification : #575, #576. **Axes prioritaires** : (1) la procédure de mise à jour et le tableau des cas des Dev Notes — aucun cas ne casse en silence, le texte ne pousse jamais à écrire un mot de passe NEUF dans `.env` ; (2) la syntaxe `${VAR:?message}` (message sans `}` ni `$`, accents) et l'étape CI dont la vérification négative doit réellement échouer ; (3) la faisabilité de l'indice sur l'erreur 1045 (ce que rend `MySqlPoolOptions::connect` en sqlx 0.8 — lis `~/.cargo/registry/src/*/sqlx-mysql-0.8*`) ; (4) le test 0600 et l'umask, la mutation M11 ; (5) l'inventaire des sites non résolus, à rejouer avec le grep du T9 ; (6) le manuel et son PDF aplati, largeurs de lignes. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Auditeur d'acceptation** : applique `.claude/skills/bmad-create-story/checklist.md`. Chaque phrase des issues couverte par un AC testable, chaque AC par une tâche, chaque tâche par un test ; implémentable sans deviner (fonctions, fichiers, codes d'erreur, clés i18n 4 locales, replis Svelte) ; faits cités revérifiés au code (`grep -nF`) ; recompte des AC et tâches. +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13-p1-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13-validate-prompt-p2.md b/_bmad-output/implementation-artifacts/15-13-validate-prompt-p2.md new file mode 100644 index 000000000..e073be6d3 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13-validate-prompt-p2.md @@ -0,0 +1,35 @@ +# Prompt — validation P2 de la spec, Story 15-13 + +*Versionné le 2026-10-09. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md`. La dernière remédiation est le commit `07e168e1` (`git show 07e168e1`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #551, #552, #576 (et #558, #575 pour le contexte). +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + la fiche). Choix C-15-13-1 à 12. **Axes prioritaires** : (1) le tri pré/post-sauvegarde des 17 sites `AdminFullImportFailed` (`routes/admin.rs`) — chaque site rangé du bon côté, aucun oublié ; (2) la faisabilité du test 17 (dossier de sauvegarde pointé sur un fichier existant) et de chaque test neuf ; (3) le décompte de modules, au seuil de cinq : recompte-le toi-même ; (4) les commandes du contrôle de fin de mise à jour (connexion TCP avec les anciens puis les nouveaux mots de passe) contre le comportement de l'image `mariadb:10.11` (comptes `root@localhost`/`root@%`, authentification par socket) ; (5) le message `:?` et `config -q` ; (6) `init-demo.sh` et ses risques ; (7) la cohérence des numéros de ligne ; (8) la ligne de `CLAUDE.md` modifiée — seule la recette doit changer ; (9) le manuel et son PDF aplati. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 07e168e1`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13-p2-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13-validate-prompt-p3.md b/_bmad-output/implementation-artifacts/15-13-validate-prompt-p3.md new file mode 100644 index 000000000..841578d6e --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13-validate-prompt-p3.md @@ -0,0 +1,35 @@ +# Prompt — validation P3 de la spec, Story 15-13 + +*Versionné le 2026-10-09. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md`. La dernière remédiation est le commit `f1eb6469` (`git show f1eb6469`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #551, #552, #576 (contexte : #558, #575, #577). +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + la fiche). Choix C-15-13-1 à 15. Issue #577 ouverte par l'orchestrateur après la P2 (la CI n'a aucun job E2E ; `docs/ci.md` en décrit un) : la fiche doit la citer au T4 au lieu de « issue à ouvrir ». **Axes prioritaires** : les alinéas neufs AC 8 e (`backup/`, `inbox/`, `documents/` dans `.gitignore` et `.dockerignore`), AC 11 m (brochure), AC 12 a-bis (`DOCKER_START.md`, 1045 avant `down -v`) ; le retrait d'`init-demo.sh` (C-15-13-13) : vraiment sans appelant, vraiment redondant avec `seed-demo`, et sa doc retirée partout ; le test 19 et le helper `tests/common/binaire.rs` (`lancer_binaire`) — peuvent-ils passer à vide ? ; le test 16 et l'interdit par phrase entière dans les 4 locales ; l'inventaire élargi (rejoue les greps du T9) ; la cohérence fiche / `sprint-status.yaml` / fiche d'epic / registre ; le manuel et la brochure, PDF aplatis. Signal D5 déclaré en P2 (8 → 6 MEDIUM distincts, deux nés de la remédiation P1) : si cette passe trouve un défaut qui revient sous une autre forme, dis-le explicitement. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show f1eb6469`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13-p3-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md b/_bmad-output/implementation-artifacts/15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md new file mode 100644 index 000000000..3fc0f9d35 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md @@ -0,0 +1,1239 @@ +# Story 15.13a : MariaDB n'est plus publiée, et ses mots de passe sont obligatoires + +Status: done + + + +**Issues** : **ferme #551**. La PR porte `closes #551` (mot-clé dans le titre ou le corps : le dépôt merge en +squash) ; les commits intermédiaires portent `refs #551`. **`refs #577`** : la story corrige le décompte +« 4 jobs » de `docs/ci.md` et y renvoie à #577 (job `e2e` décrit mais absent de `ci.yml`, smoke E2E annoncé +par le `CLAUDE.md`), **sans** la fermer. **#575** (section Synology du manuel) reste hors story : citée, pas +fermée. **#554** (mot de passe admin de `docker-compose.dev.yml`) : citée. L'angle mort « mot de passe root +resté au gabarit » est tracé par l'issue que l'orchestrateur ouvre (§ *Angles morts* ; numéro à reporter +avant le développement). + +**Dépendances** : après la **15-11a** et la **15-11b** (mergées dans `de285ea8`). **Indépendante de la 15-13b +en code** : aucune des deux n'appelle ce que l'autre ajoute. Elles touchent en revanche les **mêmes +fichiers**, à des endroits distincts : `docker-compose.yml`, `.env.example`, `crates/kesh-api/src/config.rs`, +`crates/kesh-api/tests/configuration_transmise.rs`, `docs/manual/fr/admin-manual.tex` (§ *Passer à la +0.13.0*), `DOCKER_START.md`, `CHANGELOG.md` `[0.13.0]` — et les **PDF versionnés** `admin-manual.pdf` et +`marketing-brochure.pdf`, que les deux régénèrent. La seconde mergée rebase et **recompte les gestes** +du paragraphe « Pour qui garde son fichier compose » (AC 11 f ; sa fiche dit les nombres) ; un conflit sur +un PDF ne se rebase pas : on fusionne les `.tex`, puis on **régénère** les PDF (`make fr`) et on rejoue les +contrôles aplatis des **deux** fiches (R4-3 de la validation P4). **Ordre suggéré : +15-13a d'abord** — #551 est P2 (sécurité), #552 et #576 P3 ; et le helper `lancer_binaire` (T3) ne concerne +qu'elle. **Coordination** avec la **15-7b3** (non mergée), qui corrige les `docker compose logs kesh` du +manuel (§ *Dépannage*) : cette story ne touche pas ces lignes (AC 11 j). Release visée : **v0.13.0** — la +section du manuel « Passer à la 0.13.0 » et l'entrée `[0.13.0]` du CHANGELOG sont **complétées**, pas +doublées. + +**Si la 15-13a est mergée seule** (la 15-13b non encore mergée au tag) : le CHANGELOG `[0.13.0]` porte +l'entrée **Sécurité** #551 (AC 13 a), la rubrique **Retiré** `init-demo.sh` (AC 13 b), et `CHANGELOG.md:46` +a ses quatre phrases MariaDB réécrites (AC 13 c) ; la phrase « la sauvegarde pré-import reste dans `/tmp` » +**reste vraie** et n'est pas touchée. Le manuel porte les parties MariaDB ; ses mentions « défaut `/tmp` » +(`:757`, `:1664`, `:1699`) restent vraies. Le paragraphe « Pour qui garde son fichier compose » compte +**quatre** gestes pour `docker-compose.yml` et **deux** pour `docker-compose.prod.yml` (AC 11 f). + +## Story + +En tant qu'**exploitant d'une installation Kesh** (serveur Docker avec `docker-compose.yml`, ou NAS +Synology), +je veux que **la base comptable ne soit pas joignable depuis le réseau, et qu'elle ne soit pas protégée par +des mots de passe publiés dans le dépôt**, +afin de **ne pas exposer mes livres à toute machine du réseau local**. + +## Le défaut, établi au sol le 2026-10-09 + +Faits revérifiés au code (`grep -nF`, worktree `kesh-15-13`, `HEAD` = `de285ea8`) : + +### #551 — MariaDB publiée sur toutes les interfaces, avec des mots de passe publiés + +| Site | Contenu | Effet | +|---|---|---| +| `docker-compose.yml:13` | `- "3306:3306"` sous `services.mariadb.ports` | MariaDB écoute sur **toutes** les interfaces de l'hôte | +| `docker-compose.yml:8` | `MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-kesh_dev_root}` | mot de passe root par défaut, publié | +| `docker-compose.yml:11` | `MARIADB_PASSWORD: ${MARIADB_PASSWORD:-kesh_dev}` | mot de passe applicatif par défaut, publié | +| `docker-compose.yml:53` | `DATABASE_URL: mysql://${MARIADB_USER:-kesh}:${MARIADB_PASSWORD:-kesh_dev}@mariadb:3306/…` | Kesh se connecte avec ce même défaut | +| `.env.example:336`, `:339` | `MARIADB_ROOT_PASSWORD=kesh_dev_root`, `MARIADB_PASSWORD=kesh_dev` — **lignes actives** | même un `.env` copié du gabarit, comme le prescrit le manuel (`admin-manual.tex:228-231`), pose les défauts publiés | +| `.env.example:328-334` | bloc intitulé « Service MariaDB bundled (**DEV ONLY** — docker-compose.yml + docker-compose.dev.yml) » | faux deux fois : `docker-compose.yml` est la **méthode d'installation recommandée** du manuel (`admin-manual.tex:204-224`), et `docker-compose.dev.yml` ne lit pas ces variables (mots de passe écrits en dur, `docker-compose.dev.yml:56-59`) | + +Le service `kesh-api` atteint MariaDB par le réseau interne de Compose (`kesh-network`, hôte `mariadb`) : +**la publication du port n'a aucun rôle dans le fonctionnement**. Aucune commande du manuel n'utilise le +port publié : toutes passent par `docker compose exec` (`admin-manual.tex:1460`, `:1511`, `:1578`, +`:2216` ; `DOCKER_START.md:102`). + +⚠️ **Un port publié par Docker contourne le pare-feu de l'hôte.** Docker insère ses propres règles +`iptables` (chaîne `DOCKER`) avant celles d'UFW : le pare-feu que recommande le manuel +(`admin-manual.tex:1998-2009`, `ufw default deny incoming`) **ne protège pas** un port publié en +`"3306:3306"` (documentation Docker, *Packet filtering and firewalls*, § « Docker and ufw »). L'exploitant +qui a suivi le manuel à la lettre croit sa base fermée ; elle ne l'est pas. + +`docker-compose.prod.yml` (installation Synology du manuel) **n'a pas de service MariaDB** — la base est +fournie par l'exploitant (`DATABASE_URL` de `.env`) : #551 ne le concerne pas, hormis la consigne de +génération du mot de passe (ci-dessous). + +**Défaut voisin, même sujet, trouvé en spécifiant** : le manuel fait générer le mot de passe MariaDB de +l'installation Synology par `openssl rand -base64 32` (`admin-manual.tex:546`), « repris dans +`DATABASE_URL` ». Une sortie base64 contient `/` avec une probabilité d'environ 1 − (63/64)^43 ≈ 49 % ; +dans une URL, `/` termine l'autorité — `mysql://kesh:ab/cd@hote:3306/kesh` ne se lit plus comme +l'identifiant `ab/cd`. Une installation sur deux suivant le manuel obtient une `DATABASE_URL` +inexploitable. **Traité ici** (AC 11 c, C-15-13-5) : `openssl rand -hex 32`. + +**Défaut voisin, trouvé en validation P3** (F-P3-3) : le manuel fait recopier à l'étape 3 +`MARIADB_PASSWORD=` (`admin-manual.tex:254`) et, pour Synology, +`DATABASE_URL=mysql://kesh:@mariadb:3306/kesh` (`:259`). Recopiées telles quelles, ces +chaînes deviennent le mot de passe applicatif — **imprimé dans le PDF distribué**, donc publié au même titre +que `kesh_dev`. Kesh le voit (il lit `DATABASE_URL`) et dispose déjà du prédicat qui le reconnaît +(`is_template_placeholder`, `config.rs:1405`, écrit par la 15-11a pour les gabarits du manuel). **Traité ici** +(AC 5 a-bis, C-15-13-17). + +## Acceptance Criteria + +*(Numérotation de la fiche unique 15-13 conservée ; les AC 7, 8, 9, 15 et 16 sont dans la 15-13b, et les +sous-points des AC 10 à 14 qui portent sur la sauvegarde aussi.)* + +### Volet A — MariaDB (#551) + +1. **Le port de MariaDB n'est plus publié par `docker-compose.yml`.** + (a) Le service `mariadb` de `docker-compose.yml` ne porte **aucune** clé `ports:` — ni `"3306:3306"`, ni + `"127.0.0.1:3306:3306"` (C-15-13-1). + (b) Un commentaire YAML à l'endroit de l'ancienne clé dit : pourquoi le port n'est pas publié (Kesh + passe par le réseau interne ; un port publié contourne UFW) ; comment accéder à la base + (`docker compose exec mariadb mariadb -u kesh -p`) ; et, **pour un dépannage ponctuel seulement**, la + forme à décommenter, liée au **loopback** (`- "127.0.0.1:3306:3306"`), jamais la forme sans adresse. + (c) `kesh-api` continue d'atteindre MariaDB par `mariadb:3306` sur `kesh-network` (inchangé). + +2. **Les deux mots de passe MariaDB n'ont plus de valeur par défaut dans `docker-compose.yml` : leur + absence fait refuser le démarrage par Compose, avec un message qui nomme la variable.** + (a) `services.mariadb.environment` : `MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?}` et + `MARIADB_PASSWORD: ${MARIADB_PASSWORD:?}`. + (b) La `DATABASE_URL` composée de `kesh-api` (`docker-compose.yml:53`) interpole + `${MARIADB_PASSWORD:?}` — **même message** qu'en (a) ; `MARIADB_USER` et `MARIADB_DATABASE` + gardent `:-kesh` (ce ne sont pas des secrets). + (c) (C-15-13-12.) Le `` ne contient ni `}` ni `$` (la syntaxe de Compose s'arrête au premier `}`), et il + doit laisser le **YAML** lisible : les valeurs de `docker-compose.yml:8`, `:11` et `:53` sont des + scalaires **non cités**, où `": "` termine la clé (« mapping values are not allowed here ») et `" #"` + ouvre un commentaire. Deux formes admises, **tranchées au T0** et écrites au Dev Agent Record : + (c1) message **sans** `": "` ni `" #"`, valeur non citée ; (c2) valeur entière entre **guillemets + doubles** (`"${MARIADB_PASSWORD:?…}"`), message sans `"` ni `\` — les guillemets simples sont exclus + par l'apostrophe de « d'administration ». Le message dit d'abord le cas **existant**, puis le cas neuf + (l'ordre inverse inviterait à écrire une valeur neuve sur une base existante) : « à poser dans .env. + Installation existante, reprendre la valeur avec laquelle la base a été créée, jamais une neuve + (manuel d'administration, Passer à la 0.13.0) ». Ses accents sont conservés si le T0 montre que + Compose les restitue tels quels ; sinon, message sans accents, et la mesure est écrite au Dev Agent + Record. Compose ne nomme qu'**une** variable manquante à la fois (la première qu'il rencontre) : le + manuel le dit (AC 11 j). + (d) **Aucun jeton `kesh_dev`** (donc ni `kesh_dev` ni `kesh_dev_root`) dans `docker-compose.yml`, + `docker-compose.prod.yml` ni `.env.example` — commentaires compris. + +3. **`.env.example` cesse de poser les mots de passe publiés.** + (a) Le bloc `Service MariaDB bundled (DEV ONLY …)` (`.env.example:328-339`) est réécrit : il sert + `docker-compose.yml` — l'installation autonome, MariaDB comprise —, pas « le développement » ; + `docker-compose.prod.yml` et `docker-compose.dev.yml` ne le lisent pas (dire pourquoi en une ligne + chacun). + (b) `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD` y sont des lignes **commentées** (`#MARIADB_…=`), + **sans valeur** — un `.env` copié tel quel fait donc refuser le démarrage par Compose (AC 2), au lieu + de créer une base aux identifiants publiés. `MARIADB_DATABASE=kesh` et `MARIADB_USER=kesh` restent + des lignes actives. + (c) Le commentaire du bloc dit, **dans cet ordre** : d'abord, **installation existante** — reprendre + la valeur avec laquelle la base a été créée, jamais une neuve (renvoi au manuel, § *Passer à la + 0.13.0*) ; **ces deux valeurs ne sont lues par MariaDB qu'à la création de la base** — les changer + dans `.env` ensuite ne change pas le mot de passe enregistré et fait échouer la connexion de Kesh + (renvoi au manuel, procédure de changement) ; ensuite seulement, **installation neuve** — générer + chaque mot de passe par `openssl rand -hex 32` (hexadécimal : un mot de passe base64 peut contenir + `/`, qui casse `DATABASE_URL`). Décommenter une ligne **sans** y écrire de valeur fait refuser Compose + (forme `:?`, AC 10 a) : le dire. + (d) Le gabarit `DATABASE_URL=` (`.env.example:47`, pour `docker-compose.prod.yml`) n'est pas + modifié, mais son commentaire dit de prendre un mot de passe hexadécimal (même raison). + +4. **Le refus de Compose est exercé en CI, dans les deux sens.** L'étape « Validate compose files » du job + `docker-build` (`.github/workflows/ci.yml:242-245`) : + (a) valide `docker-compose.yml` **avec** des valeurs factices + (`MARIADB_ROOT_PASSWORD=ci MARIADB_PASSWORD=ci docker compose -f docker-compose.yml config -q`) — sans + elles, l'étape actuelle échouerait désormais ; + (b) exige que la même commande **sans** ces variables (`env -u MARIADB_ROOT_PASSWORD -u MARIADB_PASSWORD`) + **échoue**, et que sa sortie d'erreur nomme `MARIADB_ROOT_PASSWORD` ou `MARIADB_PASSWORD` ; + (c) valide `docker-compose.prod.yml` comme aujourd'hui ; + (d) (C-15-13-15, rectifié par C-15-13-20.) Son commentaire d'en-tête est mis à jour (il dit aujourd'hui « les variables obligatoires non + posées ne font qu'avertir », faux pour `docker-compose.yml`), et `docs/ci.md` (`:12`, `:117`) dit les + deux sens du contrôle ; son décompte « 4 jobs » (`:9-10`), faux, devient « 3 jobs » (T4). + +5. **Kesh avertit fortement au démarrage quand le mot de passe de `DATABASE_URL` est un mot de passe + publié.** (C-15-13-2 : avertir, pas refuser ; C-15-13-17 pour le gabarit.) + (a) `Config::from_env` extrait le mot de passe de `DATABASE_URL` (URL analysée par la crate `url`, + dont `Url::password()` rend la forme **encodée** ; **décodé** par `percent_encoding::percent_decode_str` + puis **`decode_utf8_lossy()`** — pas par `form_urlencoded`, qui décode `+` en espace ; pas par + `decode_utf8()`, dont l'`Err` sur des octets non UTF-8 (`%FF`) inviterait un `?` ou un `unwrap` qui + ferait échouer ou paniquer le démarrage, contraire au (b) : une valeur non UTF-8 n'est ni publiée ni + gabarit, et sa forme décodée avec `U+FFFD` ne déclenche rien, C-15-13-22) et, s'il figure dans la liste fermée + `MOTS_DE_PASSE_PUBLIES = ["kesh_dev", "kesh_dev_root"]` (comparaison exacte, sensible à la casse), + émet **un** `tracing::warn!` qui dit : le mot de passe est publié dans le dépôt Kesh ; acceptable pour + le développement seulement ; renvoi au manuel (§ *Passer à la 0.13.0*, changement du mot de passe). + Le message **ne contient pas** le mot de passe ni l'URL (mutation M43). + (a-bis) **Gabarit du manuel** (F-P3-3, C-15-13-17) : le même contrôle émet **un** `tracing::warn!` quand + le mot de passe **décodé** satisfait `is_template_placeholder` (`config.rs:1405` — réemploi, pas de + second prédicat : DRY). Message **distinct** de (a), pour que l'exploitant sache quoi faire : le mot + de passe de `DATABASE_URL` est un **placeholder de gabarit** (par exemple une valeur entre chevrons `<…>` + recopiée du manuel — le prédicat reconnaît aussi `generate_me`, `config.rs:1391`, R4-5) ; il + protège la base comme un mot de passe publié ; le changer par la procédure du manuel (§ *Changer un + mot de passe MariaDB*, AC 11 g — `ALTER USER` d'abord, `.env` ensuite). Même règle : ni le mot de + passe, ni l'URL (M43). Un même mot de passe ne peut satisfaire (a) et (a-bis) à la fois (aucun élément de la + liste n'est entre chevrons) : un seul avertissement au plus. + (b) Kesh **démarre quand même** : aucun `ConfigError` neuf. Une URL illisible, sans mot de passe ou + au mot de passe hors liste et non gabarit n'émet rien de ce chef. Une URL qui ne s'analyse pas (mot de + passe contenant `/`, `?` ou `#` non encodés : l'autorité est tronquée) n'émet rien non plus — c'est le + cas de l'ancien `openssl rand -base64 32` du manuel, que l'AC 11 c remplace. + (c) La lecture de `DATABASE_URL` reste celle d'aujourd'hui (`env_nonempty`) : **aucune** lecture + d'environnement neuve (le test (L) de `configuration_transmise.rs` reste vert sans nouvelle entrée + d'`EMPLACEMENTS_AUTORISES`). + (d) Le doc-comment d'`is_template_placeholder` (`config.rs:1403-1404`, « Sert aux contrôles de + `KESH_JWT_SECRET` et de `KESH_ADMIN_PASSWORD` ») nomme le troisième appelant. + +6. **Un refus d'authentification de MariaDB au démarrage est expliqué, pas seulement rapporté.** + (a) Quand la connexion initiale (`main.rs:71-89`) échoue sur l'erreur MariaDB **1045** (*Access + denied*), le message d'erreur journalisé ajoute un **indice** : « MariaDB refuse l'identifiant de + DATABASE_URL. Avec docker-compose.yml : MARIADB_PASSWORD n'est lu qu'à la création de la base — + changer sa valeur dans .env ne change pas le mot de passe enregistré (manuel d'administration, Passer à + la 0.13.0). » L'erreur d'origine reste dans le message ; le processus sort toujours en code 1. Ni le + message ni l'indice ne citent `database_url` ni son mot de passe (mutation M44, test 10). + (b) L'indice vient de **deux** fonctions publiques de `kesh-api` (`config.rs`) — `main.rs` ne fait + que les composer : l'**extraction** `numero_erreur_mariadb(&sqlx::Error) -> Option` + (`as_database_error()?.try_downcast_ref::()?.number()`, forme déjà employée + `routes/reconciliation.rs:1136` et `kesh-db/src/retry.rs:106`), et la **décision** + `indice_connexion(Option) -> Option<&'static str>`, pure. La séparation tient à un fait : + `MySqlDatabaseError` n'a pas de constructeur public (`sqlx-mysql-0.8.6/src/error.rs:11`, + `pub(super) ErrPacket`), si bien qu'un test unitaire ne peut pas fabriquer l'erreur d'entrée ; + l'extraction est couverte par le test 10 (binaire réel), la décision par le test 9. Toute autre + erreur (hôte injoignable, base absente : 1049, droits sur la base : 1044) ne donne pas d'indice. + (c) **Mesure d'abord (T0)** : que `MySqlPoolOptions::connect` rende bien une `sqlx::Error::Database` + dont le numéro MariaDB vaut 1045 pour un mauvais mot de passe. Lu à la source en validation P1 : + `sqlx-core-0.8.6` ne rejoue que `ConnectionRefused` et les erreurs `is_transient_in_connect_phase()` + (`pool/inner.rs:346-383`), que `sqlx-mysql` ne redéfinit pas — le 1045 doit donc sortir + immédiatement en `Error::Database`. Si la mesure le contredit, la reconnaissance se fait sur ce qu'elle + montre ; un repli **textuel** exige `(using password` et non `Access denied`, qui attraperait aussi le + 1044 (*Access denied for user … to database*). La mesure est écrite au Dev Agent Record. + +### Volet C — le garde-fou et la documentation (parties MariaDB) + +10. **`configuration_transmise.rs` garde ces décisions.** *(Le sous-point (c), montage de la sauvegarde, et + la partie « trois montages » de (e) sont dans la 15-13b.)* + (a) **MariaDB** : un contrôle neuf lit le service `mariadb` de `docker-compose.yml` (extraction + généralisée de `service_kesh_api` en `service(source, nom)`, DRY ; le `Service` gagne la présence de la + clé `ports:`) et rougit si : la clé `ports:` est présente ; `MARIADB_ROOT_PASSWORD` ou + `MARIADB_PASSWORD` n'est pas de la forme `${NOM:?…}` sur **elle-même**. **La variante + `Interpolation::Obligatoire` est scindée** (`configuration_transmise.rs:541-564`, et le bras de `match` + de `controle_valeur`, `:626`, qui ne compile plus après la scission — R4-2) : `ObligatoireNonVide` + (`:?`, refuse une variable absente **ou vide**) et `ObligatoireSiAbsente` (`?`, ne refuse qu'une + variable absente). Le contrôle exige la première : avec `?`, la ligne `#MARIADB_PASSWORD=` du gabarit + décommentée **sans valeur** — le geste le plus naturel — ferait démarrer Compose avec un mot de passe + vide. **La `DATABASE_URL` de `kesh-api`** n'est **pas** contrôlée par ce test neuf : elle l'est par la + famille (V) existante (`controle_valeur`, `configuration_transmise.rs:572-663`, test `valeurs`), en + **resserrant** le motif de `VALEURS_COMPOSEES` (`:169-174`) de `${MARIADB_` à `${MARIADB_PASSWORD:?` — + pas par un second contrôle sur la même valeur (DRY). La valeur est lue **après** l'analyse YAML : une + valeur citée (forme c2 de l'AC 2 c) arrive sans ses guillemets. + (b) **Mots de passe publiés** : aucun des trois fichiers distribués (`docker-compose.yml`, + `docker-compose.prod.yml`, `.env.example`) ne contient `kesh_dev` (recherche de sous-chaîne, qui couvre + `kesh_dev_root`) ; dans `.env.example`, toute ligne d'affectation de `MARIADB_ROOT_PASSWORD` ou de + `MARIADB_PASSWORD` est commentée (même extracteur `lignes_affectation` que (E)). + (d) **Auto-test (S)** : des sources synthétiques exercent le contrôle (a) — refusées : clé `ports:` + présente ; `${MARIADB_PASSWORD:-x}` ; `${MARIADB_PASSWORD}` ; `${MARIADB_PASSWORD?m}` (forme `?`) ; + acceptées : `${MARIADB_ROOT_PASSWORD:?m}` et la même valeur **citée** (`"${MARIADB_ROOT_PASSWORD:?m}"`). + (e) L'en-tête du fichier (`//!`) dit ce que le test établit désormais du service `mariadb`, et + l'angle mort qui reste : `docker-compose.dev.yml` non contraint (inchangé). + +11. **Le manuel d'administration dit vrai, et le PDF aussi** (parties MariaDB). `docs/manual/fr/admin-manual.tex` : + (a) **Ports réseau** (`:162-177`) et **Référence des ports** (`:2354-2364`) : MariaDB 3306 — « non + publié par les compose fournis (réseau interne Docker) » ; l'avertissement dit qu'un port publié par + Docker **contourne UFW**. + (b) **Étape 3 — `.env`** (`:229-270`) : mots de passe MariaDB par `openssl rand -hex 32` ; sans eux, + Compose refuse de démarrer en nommant la variable ; ils ne sont lus qu'à la **création** de la base. + La phrase « pour les mots de passe MariaDB, rien ne la refuse — choisissez-les vous-même » est + corrigée : Compose refuse l'absence ; une valeur gabarit `<…>` est acceptée par Compose et par + MariaDB — **Kesh avertit au démarrage** quand le mot de passe **applicatif** l'est (AC 5 a-bis), + **rien** ne signale un mot de passe **root** gabarit (Kesh ne le voit pas : angle mort) — le dire. Et, + au même endroit : une base **déjà créée** avec la chaîne gabarit littérale (l'étape 3 actuelle, + `admin-manual.tex:253-254`, fait recopier `MARIADB_ROOT_PASSWORD=`) garde ce mot de + passe ; on ne le remplace **pas** dans `.env` — ce serait un 1045 —, on le change par la procédure de + l'AC 11 g (`ALTER USER` d'abord, `.env` ensuite). La relecture des valeurs entre chevrons que + prescrit la procédure de mise à jour (`:1746`, « vérifier les placeholders de `.env` ») nomme ces deux + lignes — c'est le seul signal pour le mot de passe root. + (c) **Synology, étape 2** (`:546`) : `openssl rand -hex 32 # → mot de passe MariaDB` au lieu de + `-base64 32` (raison : `/` dans `DATABASE_URL`). + (d) **`sec:env-vars`** — note MariaDB (`:689-694`) complétée (obligatoires avec `docker-compose.yml`, + lues à la création seulement). **Les dix tableaux de `sec:env-vars` restent sans `Overfull \hbox`** + (acquis de la 15-11a). *(La ligne `KESH_ADMIN_BACKUP_DIR` et le paragraphe des montages : 15-13b.)* + (f) **`sec:maj-0-13`** (`:1757-1841`) — le paragraphe **MariaDB** (pour `docker-compose.yml`) ; le + paragraphe **sauvegarde** est dans la 15-13b. Détail en Dev Notes, § *La mise à jour d'une + installation existante* : re-télécharger le compose suffit pour le port ; `.env` sans + `MARIADB_ROOT_PASSWORD` / `MARIADB_PASSWORD` → refus nommé de Compose, et il faut alors y écrire **la + valeur avec laquelle la base a été créée** (pour qui n'en avait jamais posé : les anciennes valeurs + par défaut, `kesh_dev_root` et `kesh_dev`, écrites **au manuel seulement**), **jamais une valeur + neuve** ; puis changer ces mots de passe par la procédure donnée (AC 11 g) ; **contrôle de fin de mise + à jour sur la base, pas sur `.env`** (C-15-13-10) : une connexion avec les **anciens** mots de passe + publiés doit être **refusée** (1045), une connexion avec ceux de `.env` doit réussir — pour `root` + et pour `kesh`, en TCP pour que l'authentification par socket ne fausse pas la mesure, par exemple + `sudo docker compose exec mariadb mariadb --protocol=TCP -h 127.0.0.1 -uroot -pkesh_dev_root -e 'SELECT CURRENT_USER()'` + (refus attendu) et + `sudo docker compose exec mariadb sh -c 'mariadb --protocol=TCP -h 127.0.0.1 -uroot -p"$MARIADB_ROOT_PASSWORD" -e "SELECT CURRENT_USER()"'` + (succès attendu) — `SELECT CURRENT_USER()` plutôt que `SELECT 1` : le résultat dit **quel compte** + a répondu (`root@localhost` ou `root@%`, selon que le serveur résout `127.0.0.1` — l'image pose + peut-être `skip-name-resolve`, non vérifié) ; le manuel dit ce que le contrôle couvre, et l'AC 11 g + traite de toute façon **chaque** compte `root` listé. Ces commandes dépassent 76 caractères : au + manuel, elles sont coupées par `\` en fin de ligne (AC 11 k) — y compris **à l'intérieur** des + apostrophes de `sh -c '…'`, où la barre suivie d'un saut de ligne est transmise à `sh`, qui la lit + comme une continuation ; forme exacte, comportement de `root@localhost`, et compte rendu par + `CURRENT_USER()` pour `-h 127.0.0.1` **et** pour `-h mariadb` **mesurés au T7** ; une + vérification `docker compose config | grep kesh_dev` ne lit que `.env` et resterait muette alors que + la base garde `kesh_dev_root` (F3 de la validation P1). Puis **lancer une fois à la main** le script + de sauvegarde (§ *Sauvegarde*) après toute mise à jour ou tout changement de mot de passe : c'est + le seul moyen de voir qu'un mot de passe root changé dans `.env` sans `ALTER USER` le fait échouer. + **Pour qui garde son compose — le paragraphe `:1793`** (F-P3-1, C-15-13-18) : son titre « Pour qui garde son + fichier compose : **deux gestes**. » et sa phrase d'ouverture « Sous `environment:` du service + `kesh-api` : » deviennent faux — les gestes de cette story ne sont pas sous `environment:` de + `kesh-api`. Le titre perd son décompte global ; le paragraphe donne le décompte **par fichier**, + recompté à l'écriture sur l'état de la branche : avec la 15-13a seule, **quatre** gestes pour + `docker-compose.yml` (les deux de la 15-11a ; retirer `ports:` de `mariadb` ; remplacer les fragments + de mot de passe) et **deux** pour `docker-compose.prod.yml` (inchangé) ; si la 15-13b est déjà mergée, + **cinq** et **trois** (elle ajoute le montage `./backup` aux deux). Les gestes neufs sont écrits + **dans** ce paragraphe, chacun avec le fichier qu'il concerne : retirer `ports:` de `mariadb` ; + **un remplacement de fragment** — `:-kesh_dev_root` et `:-kesh_dev` (trois occurrences : + `MARIADB_ROOT_PASSWORD`, `MARIADB_PASSWORD`, `DATABASE_URL`) deviennent `:?` suivi d'un message ; le + manuel recommande de **recopier** ces lignes depuis le compose re-téléchargé et ne reproduit **pas** la + ligne `DATABASE_URL` entière ni le message long de l'AC 2 c, qui dépasseraient les 76 caractères de + l'AC 11 k ; il montre le fragment, par exemple `${MARIADB_PASSWORD:?à poser dans .env}`. Contrôle + écrit après les gestes : `docker compose config` ne montre **aucune** entrée `ports` sous le service + `mariadb` (graphie de la forme longue relevée au T0). Puis avertissement « Kesh avertit au démarrage » + (AC 5) et « indice 1045 » (AC 6). **Le refus de Compose frappe toute sous-commande**, pas seulement + `pull` et `up -d` : Compose interpole le fichier entier au chargement, si bien que + `docker compose ps`, `logs`, `exec`, `stop` et `down` refusent aussi tant que `.env` n'est pas + complété — l'exploitant ne peut alors ni diagnostiquer ni arrêter proprement, et le script de + sauvegarde lancé par `cron` échoue dès que `.env` perd une des deux lignes ; le manuel le dit (mesuré + au T0 par `docker compose ps` sur la copie sans `.env`). **Le contrôle des placeholders** + (`:1780-1786`, `sudo docker compose config | grep -iE …`, « la commande doit rester muette ») devient + trompeur : quand Compose refuse, `grep` ne reçoit rien et reste muet — muet comme un succès. La + procédure pose donc les deux mots de passe **avant** ce contrôle, et le fait précéder de + `sudo docker compose config -q && echo 'compose lisible'`, dont la seconde moitié **doit** + s'afficher : sans elle, le silence du `grep` ne prouve rien (même correction au CHANGELOG, AC 13 d). + **Propagation dans l'encadré `:1761-1776`** : la liste « Refus de démarrer » + (`:1764`, qui renvoie à `docker compose logs kesh-api`) ne reçoit **pas** le refus de Compose tel + quel — il s'affiche **au terminal** de la commande lancée, et aucun conteneur ne démarre : + il est écrit à part, comme un refus distinct de ceux de Kesh ; le paragraphe « Un `.env` copié du + gabarit sans modification » (`:1775`) devient faux — un `.env` copié du **nouveau** gabarit fait + refuser Compose (mots de passe commentés), un `.env` copié d'un **ancien** gabarit fait émettre + l'avertissement de l'AC 5 —, et son « sauf » est complété. Le manuel fait partie du corpus du + contrôle (F) de `configuration_transmise.rs` (`:899-920`) : ne nommer **aucun** jeton `KESH_…` + inexistant, pas même pour dire qu'il n'existe pas. + (g) **Changer un mot de passe MariaDB** (sous-section neuve, dans `sec:maj-0-13` ou juste après, + `\label{sec:mariadb-mdp}`) : lister les comptes (`SELECT user, host FROM mysql.user;`), `ALTER USER` + pour `kesh` et pour **chaque** compte `root` listé, **puis** `.env`, **puis** + `docker compose up -d` ; la recette est **rejouée** sur un conteneur jetable (T7) avant d'être écrite. + C'est le renvoi de l'avertissement de l'AC 5 a-bis. **Hygiène et fenêtre** (F7 de la validation P4, + C-15-13-23) : le nouveau mot de passe n'apparaît **ni dans l'historique du shell** (il n'est jamais + tapé en clair : engendré dans une variable, `NEW=$(openssl rand -hex 32)`) **ni dans la liste des + processus de l'hôte** (l'`ALTER USER` atteint `mariadb` par l'**entrée standard** — document en ligne + `<_kesh-mariadb-data` (préfixe du projet Compose). + (a-bis) **« Base de données ne s'initialise pas »** (`DOCKER_START.md:134-137`) prescrit aujourd'hui + `docker compose down -v` puis `docker compose up --build`. Après la story, la cause la plus probable + d'une base « qui ne démarre pas » sur une installation existante est un **1045** — mot de passe neuf + écrit dans `.env` alors que MariaDB garde celui de la création (§ *La mise à jour d'une installation + existante*) — et `down -v` le « résout » en **supprimant le volume**, c'est-à-dire les livres. Le + paragraphe est réécrit : **diagnostic d'abord** (`docker compose logs kesh-api` et + `docker compose logs mariadb` ; un 1045 ou l'indice de l'AC 6 = mot de passe → revenir à la valeur + d'origine, renvoi au manuel § *Passer à la 0.13.0* ; un refus de Compose nommant une variable → la + poser dans `.env`) ; `down -v` n'y figure plus que pour une installation **sans données à conserver**, + avec l'avertissement qu'il supprime le volume de la base, **définitivement**. `DOCKER_START.md:78` + (« Arrêter et supprimer les données », `down -v` explicite) reste tel quel : il dit ce qu'il fait. + (b) **`init-demo.sh` est supprimé du dépôt** (C-15-13-13, qui remplace C-15-13-11). Il visait par + défaut le conteneur `kesh-mariadb` de `docker-compose.yml` avec `kesh_dev` en dur (`:20`, `:23`, + `:69`), et la correction de la P1 (identifiant lu dans le conteneur) le rendait **opérant** sur toute + installation conforme : ses `DELETE` successifs (`:70-75`, commentaire `:70`), sans transaction ni + confirmation, auraient vidé `users` et `onboarding_state` avant que `DELETE FROM companies` n'échoue + sur les 25 clés étrangères `ON DELETE RESTRICT` d'une base peuplée (F2 de la validation P2). Il est + **redondant** : la démonstration est semée par l'application elle-même + (`POST /api/v1/onboarding/seed-demo`, `routes/onboarding.rs:177-190`, qui appelle + `kesh_seed::seed_demo` — plan comptable complet par les repositories, exercice, `is_demo`, + réinitialisation par `reset_demo`), alors que le script écrit en SQL brut dix comptes sans rôle ni + exercice et un administrateur `admin`/`admin123`. **Personne ne l'appelle** : `git grep -n init-demo` + hors `_bmad-output` ne rend que le fichier lui-même (aucun script, aucune CI, aucune documentation, ni + `README.md`, ni `DOCKER_START.md`, ni manuel, ni site). Le retrait supprime le risque au lieu de le + borner ; l'historique git le garde. **Vérifié après coup** par + `git grep -n init-demo -- . ':(exclude)_bmad-output' ':(exclude)CHANGELOG.md'` → vide : le CHANGELOG + est exclu parce que l'AC 13 b y **exige** la mention du retrait (R3-2) ; cette ligne entre à + l'inventaire comme résolue, voulue. + +13. **CHANGELOG `[0.13.0]`** (parties MariaDB). + (a) **Sécurité** — entrée #551 : MariaDB n'est plus publiée ; plus de mots de passe par défaut ; + `.env.example` ne les pose plus ; avertissement au démarrage (mot de passe publié **ou** gabarit du + manuel recopié, AC 5) ; **action requise** (re-télécharger le compose ; sans les deux variables, + Compose refuse de démarrer en les nommant ; y écrire les valeurs d'origine, jamais des neuves, puis + les changer selon le manuel ; outils qui se connectaient au port 3306 de l'hôte → + `docker compose exec mariadb`). Et la consigne hexadécimale pour la `DATABASE_URL` de Synology. Elle + dit aussi la limite : Kesh ne voit pas le mot de passe **root** — un `kesh_dev_root` recopié d'un + ancien `.env`, ou un root resté au gabarit `<…>`, ne déclenche aucun avertissement ; seuls le contrôle + de fin de mise à jour du manuel (connexion refusée avec l'ancien mot de passe, AC 11 f) et la relecture + des chevrons de `.env` le montrent. + (b) Rubrique **Retiré** : `init-demo.sh` (démonstration semée par l'écran d'accueil ; AC 12 b). Aucune + version du `CHANGELOG.md` ne porte encore cette rubrique (F7 de la validation P4) ; elle est légitime : + *Removed* est l'une des six rubriques de Keep a Changelog 1.1.0, que l'en-tête du fichier déclare suivre + (vérifié le 2026-10-09 sur `keepachangelog.com/fr/1.1.0/` : *Added*, *Changed*, *Deprecated*, + *Removed*, *Fixed*, *Security* — la page française garde les intitulés anglais) ; le fichier en traduit + les intitulés (Ajouté, Modifié, Corrigé, Sécurité), « Retiré » traduit *Removed*. Elle se place entre + « Modifié » et « Corrigé », dans l'ordre de la convention. *(Les + entrées #552, #576 et « dossiers montés ignorés » : 15-13b.)* + (c) **Propagation** : `CHANGELOG.md:46` porte **cinq** phrases que la 15-13 rend fausses (F-P3-1 : la + fiche unique en comptait quatre, « deux gestes » lui avait échappé) ; **quatre** relèvent de la + 15-13a, toutes réécrites : « le manuel d'administration décrit les **deux gestes** » (le décompte + par fichier de l'AC 11 f — quatre pour `docker-compose.yml`, deux pour `docker-compose.prod.yml`, ou + cinq et trois si la 15-13b est mergée — ou un renvoi sans nombre) ; « Kesh **refuse de démarrer** + si … » (la liste omet le refus de **Compose**, qui s'affiche au terminal — le dire à part, comme au + manuel) ; « Une variable que `.env` ne pose pas, ou laisse vide, prend son défaut sans + avertissement » (faux pour `MARIADB_ROOT_PASSWORD` et `MARIADB_PASSWORD` avec `docker-compose.yml` ; + la fin de la phrase, « — la sauvegarde pré-import reste dans `/tmp` », relève de la 15-13b et reste + vraie tant qu'elle n'est pas mergée) ; « Un `.env` copié du gabarit sans modification ne change rien + d'autre que le refus du placeholder du secret JWT » (un `.env` copié du **nouveau** gabarit fait + refuser Compose ; d'un **ancien**, il fait émettre l'avertissement de l'AC 5). + (d) **Propagation (F-P3-2)** : `CHANGELOG.md:52` (entrée #557, rubrique **Sécurité**) recopie le + contrôle des placeholders `sudo docker compose config | grep -iE …` avec « doit rester muette » — + le piège corrigé au manuel à l'AC 11 f (muet quand Compose refuse). Il est remplacé comme au manuel : + les deux mots de passe MariaDB posés d'abord, puis `sudo docker compose config -q && echo 'compose + lisible'`, dont la seconde moitié doit s'afficher, puis le `grep` — ou un simple renvoi au manuel + (§ *Passer à la 0.13.0*) sans recopier la commande ; le choix est écrit au Dev Agent Record. + +14. **Inventaire des sites non résolus tenu** (§ *Inventaire* des Dev Notes) : à la fin du développement, + chaque site de l'inventaire est soit résolu par un AC, soit écrit comme angle mort, et les greps de + contrôle (T9) ne trouvent **aucun** site hors de l'inventaire. + +## Tasks / Subtasks + +- [x] **T0 — Mesures avant d'écrire** (AC 2 c, 6 c, 11 f, 11 j, 11 l ; test 8) + - [x] `docker compose -f docker-compose.yml config -q` sur une copie du compose portant le message de + l'AC 2 c, **sous ses deux formes** (c1 non citée, c2 entre guillemets doubles), **avec** les deux + variables posées (le fichier doit se lire : c'est ce que le YAML casserait) puis **sans** elles : + relever le message exact (accents restitués ?), la forme retenue, et **laquelle des deux + variables Compose nomme en premier** quand les deux manquent — version de Compose au Dev Agent + Record. Sur la même copie sans les variables : `docker compose ps` (et `logs`) refusent-ils aussi + (AC 11 f) ? Relever. Sur la copie **avec** les variables : graphie de `docker compose config` pour + un service sans `ports` et pour un service qui en a (forme longue `published:`), pour le contrôle + écrit de l'AC 11 f. + - [x] Erreur `sqlx` d'un mauvais mot de passe sur la MariaDB de dev (base du worktree, **jamais** le + conteneur `kesh-mariadb-dev` redémarré) : variante d'`sqlx::Error`, numéro 1045 joignable ou non. + - [x] Durée de l'échec de connexion du test 8 : hôte `kesh-15-13.invalid` (échec de résolution, non + rejoué par `sqlx`) contre `127.0.0.1:1` (`ConnectionRefused`, rejoué jusqu'à + `db_connect_timeout` = 10 s, `config.rs:639`) ; retenir le premier s'il échoue vite **et** produit + « Base de données indisponible », sinon le second, durée écrite. + - [x] `make fr` sur l'état d'avant : garder le `.log` (référence des `Overfull`) et le PDF aplati + (référence des contrôles de l'AC 11 l, chacun **rouge** dessus). +- [x] **T1 — Compose, gabarit** (AC 1, 2, 3) + - [x] `docker-compose.yml` : `ports:` de `mariadb` retiré + commentaire ; trois `:?` (forme du T0). + - [x] `.env.example` : bloc MariaDB réécrit (ordre de l'AC 3 c), lignes commentées ; commentaire + `DATABASE_URL` (hex). + - [x] `grep -rnF kesh_dev docker-compose.yml docker-compose.prod.yml .env.example` → vide. +- [x] **T2 — Code** (AC 5, 6) + - [x] `config.rs` : `MOTS_DE_PASSE_PUBLIES` + avertissement (a) **et** avertissement gabarit (a-bis) par + `is_template_placeholder` (doc-comment complété, AC 5 d) — crates `url` **et** `percent-encoding` + en dépendances directes de `kesh-api` (`url` ne réexporte pas `percent_encoding` ; toutes deux + déjà au `Cargo.lock`, `url` 2.5.8 et `percent-encoding` 2.3.2 : aucun paquet neuf) ; + `numero_erreur_mariadb` et `indice_connexion` (AC 6 b). + - [x] `main.rs:82-87` : le message d'erreur ajoute l'indice quand la composition des deux fonctions en + rend un. +- [x] **T3 — Tests Rust** (AC 5, 6, 10) — voir § *Tests et mutations*. Dans + `configuration_transmise.rs` : scission d'`Interpolation::Obligatoire`, `VALEURS_COMPOSEES` + resserré (AC 10 a, test `valeurs`). Le lancement du binaire est factorisé (DRY) : + `sortie_du_binaire_sans_configuration` (`configuration_transmise.rs:2232`, privée) devient un appel + d'un helper `lancer_binaire(env: &[(&str, &str)]) -> std::process::Output` (répertoire temporaire + courant, `env_clear`, `NO_COLOR=1`, puis les couples donnés) posé dans + `crates/kesh-api/tests/common/binaire.rs` et inclus par `#[path = "common/binaire.rs"] mod binaire;` + dans `configuration_transmise.rs` et `demarrage_mariadb.rs` — **pas** par `mod common;`, qui + tirerait dans ces deux binaires l'amorce de base de `common/mod.rs` qu'ils n'utilisent pas. + **Le helper ne fait aucune assertion** (F-P3-9) : chaque appelant pose la sienne — + `sortie_du_binaire_sans_configuration` garde son `!sortie.status.success()` + (`configuration_transmise.rs:2232-2254`), les tests 8 et 10 posent « code de sortie ≠ 0 ». +- [x] **T4 — CI** (AC 4) : `.github/workflows/ci.yml` et `docs/ci.md` — dont le « 4 jobs (`backend`, + `frontend`, `e2e`, `docker-build`) » de `docs/ci.md:9-10` (ligne 9 : pull request ; ligne 10 : + « mêmes 4 jobs » au push), faux (`ci.yml` n'en a que trois : `backend:` `:21`, `frontend:` `:189`, + `docker-build:` `:233` ; aucun job `e2e`), corrigé en « 3 jobs (`backend`, `frontend`, + `docker-build`) » dans le même patch que `:12` et `:117`. Le même symptôme, grepé par la valeur + (`grep -n e2e docs/ci.md`), court ailleurs dans le fichier : `:23` (schéma), `:30` (tableau des + jobs), `:131`, `:164`, `:186`, `:210` décrivent un job `e2e` absent de `ci.yml` (`:66`, + `npm run test:e2e`, est une recette locale et reste juste) — défaut **antérieur** à la story, hors de + son sujet : ces lignes ne sont pas réécrites ; une phrase sous `:10` dit que les sections qui + décrivent `e2e` ne correspondent à aucun job de `ci.yml` et renvoie à **#577** (le `CLAUDE.md` + affirme lui aussi un smoke E2E en CI que `ci.yml` ne porte pas — même issue, `CLAUDE.md` non modifié + ici). +- [x] **T5 — Manuel et brochure** (AC 11) : sites (a)–(d), (f)–(h), (j) et (m), puis `make fr` (trois + PDF), contrôles (k)–(m). +- [x] **T6 — `DOCKER_START.md`, retrait d'`init-demo.sh`, CHANGELOG** (AC 12, 13) — `git rm init-demo.sh`. +- [x] **T7 — Recette de changement de mot de passe rejouée** (AC 11 f-h) sur un **conteneur MariaDB + jetable** (`docker run --rm -d --name kesh-15-13-recette mariadb:10.11`, sans port publié, réseau + jetable, **jamais** `kesh-mariadb-dev`) : initialisé avec `kesh_dev`/`kesh_dev_root`, recette + appliquée ; **contrôle de fin de mise à jour** de l'AC 11 f rejoué tel qu'il sera écrit (forme + coupée par `\`) — connexion avec les anciens mots de passe **refusée**, avec ceux de + l'environnement **acceptée**, pour `root` et `kesh` ; `SELECT CURRENT_USER()` relevé pour + `-h 127.0.0.1` **et** pour `-h ` (quel compte `root` répond dans chaque + cas : `root@localhost` ou `root@%`) ; comportement de `root@localhost` par socket relevé + (l'authentification `unix_socket` laisserait-elle entrer `root` avec un mauvais mot de passe ? d'où + `--protocol=TCP`) ; `healthcheck.sh --connect --innodb_initialized` toujours vert ; **dump puis + restauration** dans la forme du § *Forme du script de sauvegarde* (une table témoin créée, dumpée, + supprimée, restaurée, relue) ; effet de `up -d kesh-api` (forme nommée, `admin-manual.tex:1747-1748`) + sur le service `mariadb` relevé ; conteneur supprimé. Commandes et sorties au Dev Agent Record. +- [x] **T8 — Mutations** : chacune appliquée, test rouge relevé, fichier restauré **et touché** + (`touch`, mémoire « mutation restaurée, binaire périmé ») ; tableau au Dev Agent Record. +- [x] **T9 — Propagation et inventaire** (AC 14) — exclusions communes + `--exclude-dir={target,node_modules,.git,_bmad-output,_bmad,.claude,.svelte-kit}` (les skills BMAD de + `.claude/` et `_bmad/` portent des `docker-compose up` étrangers au dépôt) : + (1) `grep -rnIE "3306|kesh_dev|exec (-T )?db\b|base64 32|init-demo|down -v|(compose|décrit les)[^.]{0,20}deux gestes|doit rester muet" …` + — chaque résultat est dans l'inventaire (résolu ou angle mort), sinon il y entre. Le jeton du décompte + des gestes est **restreint** (validation P4, R4-1/F1) : `deux gestes` seul rendait six homonymes sans + rapport (`CLAUDE.md:760`, `crates/kesh-api/src/routes/users.rs:334`, + `crates/kesh-api/tests/users_e2e.rs:560`, `crates/kesh-db/src/repositories/invoice_settlements.rs:395`, + `docs/manual/fr/user-manual.tex:1309`, `frontend/tests/e2e/invoice-unvalidate.spec.ts:67`) ; la forme + restreinte, rejouée à `c702b7d5`, ne rend que `CHANGELOG.md:46` et `admin-manual.tex:1793`, les deux + sites traités. Le grep entier, rejoué au même commit, ne rend aucun site hors de l'inventaire + ci-dessous ; + (2) `grep -rn "MARIADB_" …` (même exclusions) ; + (3) un grep **par la valeur** du geste de démarrage, que les jetons ci-dessus ne voient pas (c'est par + là que la brochure avait échappé à l'inventaire), **sur tout le dépôt** et non plus sur une liste de + fichiers (F-P3-6 : `CHANGELOG.md`, `.env.example` et les commentaires des compose y échappaient) : + `grep -rnE "docker[ -]compose( -f [^ ]+)? up" …` (les `.tex` et le HTML ; les PDF par leur aplati) — + chaque site qui démarre `docker-compose.yml` doit soit venir **après** la pose des deux mots de passe, + soit les nommer, soit être trié comme assumé ; + (4) `grep -rnE "config \| grep|config \\\\$" …` puis lecture de la ligne suivante : tout contrôle + « doit rester muet » qui suit un `docker compose config` est précédé du `config -q && echo` (AC 11 f, + 13 d). +- [x] **T10 — Gates** : `scripts/test-fast.sh` complet (base du worktree remise à zéro avant — + `DROP`/`CREATE` de **ses** bases, jamais un redémarrage du conteneur), frontend non touché (gate + frontend tout de même, CLAUDE.md), **E2E complet au dernier commit de code** (D7) ; la story ne + touche aucune migration (exception `kesh-db` sans objet). + +## Tests et mutations + +Périmètre : tests **neufs ou modifiés** par la 15-13a, de `de285ea8` au commit de développement. +Numérotation de la fiche unique conservée ; la ligne **3a** est neuve (validation P3, ci-dessous). + +| # | Test (fichier) | Établit | Mutation qui le rend rouge | +|---|---|---|---| +| 1 | `mariadb` (nouveau `#[test]`, `configuration_transmise.rs`) | AC 10 a | **M1** remettre `ports: ["3306:3306"]` au service `mariadb` · **M2** remettre `"127.0.0.1:3306:3306"` actif · **M3** `MARIADB_PASSWORD: ${MARIADB_PASSWORD:-kesh_dev}` · **M4** `${MARIADB_ROOT_PASSWORD}` (sans `?`) · **M28** `MARIADB_PASSWORD: ${MARIADB_PASSWORD?m}` (forme `?`, qui laisse passer une ligne vide) | +| 2 | `mots_de_passe_publies` (nouveau, même fichier) | AC 10 b | **M6** ligne active `MARIADB_PASSWORD=kesh_dev` dans `.env.example` · **M7** `kesh_dev_root` dans un **commentaire** de `docker-compose.yml` · **M8** `MARIADB_ROOT_PASSWORD=x` décommentée (valeur quelconque) | +| 3a | `valeurs` (existant, `configuration_transmise.rs:1743`, famille (V) — `VALEURS_COMPOSEES` resserré à `${MARIADB_PASSWORD:?`) | AC 10 a (`DATABASE_URL`) | **M5** `DATABASE_URL` en `${MARIADB_PASSWORD:-x}` seulement | +| 4 | `s_service_mariadb` (nouveau, (S)) — sources refusées et acceptées de l'AC 10 d, dont la valeur **citée** | AC 10 d | **M12** le contrôle accepte `DefautSiVide` comme forme obligatoire · **M13** le contrôle ignore la clé `ports:` · **M29** l'analyseur range `?` avec `:?` (scission annulée) | +| 7 | `from_env_warns_on_published_database_password` (nouveau, `config.rs`, capture `from_env_with_logs`) — cas `kesh_dev`, `kesh_dev_root`, `kesh%5Fdev` (décodé) → avertissement (a) ; **gabarits** : `mysql://kesh:%3Cmot%20de%20passe%20utilisateur%20applicatif%3E@h/kesh` (encodé) **et** la même URL non encodée, telle que Compose la compose depuis `.env` (`mysql://kesh:@h/kesh`), et `mysql://kesh:%3CMARIADB_PASSWORD%3E@h/kesh` → avertissement (a-bis), **distinct** de (a) — la forme non encodée **s'analyse** : établi à la source en validation P4 (`url` 2.5.8, `parser.rs:880-952`, `parse_userinfo` encode l'espace, `<` et `>` par le jeu `USERINFO`, `:20-37` ; `Url::password()` rend `%3Cmot%20de%20passe…%3E`, que le décodage restitue), cas **positif ferme**, sans repli ; **témoins** : `mysql://kesh_dev:fort@…` (l'utilisateur, pas le mot de passe), `Kesh_Dev` (casse), `a%3Cb%3Ec` (chevrons **intérieurs**, admis par le prédicat), `%FF` (octet non UTF-8 : ni panique ni avertissement, AC 5 a), mot de passe fort, URL sans mot de passe → aucun avertissement ; **aucun message capturé ne contient le mot de passe ni l'URL** (pour chaque cas averti, sous ses formes encodée et décodée) | AC 5 a, a-bis, b | **M16** avertissement (a) retiré · **M17** comparaison sur l'utilisateur · **M18** sans décodage pour cent · **M19** comparaison insensible à la casse · **M42** appel d'`is_template_placeholder` retiré (seule la liste reste) · **M43** l'avertissement (a) ou (a-bis) interpole le mot de passe décodé (F6 de la validation P4) | +| 8 | `mot_de_passe_publie_avertit_au_demarrage` (nouveau, lance le binaire comme `rust_log_vide_vaut_info` : `DATABASE_URL=mysql://kesh:kesh_dev@/kesh` — `kesh-15-13.invalid` si l'échec est immédiat, sinon `127.0.0.1:1` et ses ≈ 10 s —, `KESH_JWT_SECRET` valide, `KESH_HOST=127.0.0.1` ; le binaire sort en échec de connexion) — fichier neuf `crates/kesh-api/tests/demarrage_mariadb.rs`, lancement par le helper `lancer_binaire` (T3) ; assertions : l'avertissement est dans la sortie, **code de sortie ≠ 0** | AC 5 (câblage) | **M20** l'avertissement émis hors de `from_env` (fonction non appelée) — assertion de montage : la sortie contient « Base de données indisponible » | +| 9 | `indice_connexion_refusee` (nouveau, unitaire, `config.rs`) — la décision pure `indice_connexion` : `Some(1045)` → indice ; `Some(1049)`, `Some(1044)`, `None` → rien | AC 6 b | **M21** 1044 au lieu de 1045 · **M22** indice pour toute erreur | +| 10 | `mauvais_mot_de_passe_donne_l_indice` (nouveau, `demarrage_mariadb.rs`) — lance le binaire avec **les mêmes variables que le test 8** (`KESH_JWT_SECRET` valide, `KESH_HOST=127.0.0.1` : `Config::from_env` exige le secret, `config.rs:646`, **avant** la connexion, `main.rs:63-69`) et la `DATABASE_URL` des tests (`DATABASE_URL` de l'environnement, **obligatoire** : panique explicite si absente, pas de saut silencieux) dont le mot de passe est remplacé par `mauvais-15-13` ; la sortie contient l'erreur d'origine **et** l'indice ; code de sortie ≠ 0 ; la sortie ne contient **pas** `mauvais-15-13` (ni l'indice ni le message ne citent l'URL ou son mot de passe) ; **assertion de montage** : la sortie ne contient **pas** « Erreur de configuration » (sinon le binaire n'a jamais tenté la connexion) — couvre l'**extraction** `numero_erreur_mariadb` | AC 6 a-b | **M23** `main.rs` n'appelle pas la fonction d'indice · **M44** le message d'échec de connexion interpole `config.database_url` | +| 13 | Étape CI « Validate compose files » (shell, `ci.yml`) | AC 4 | **M27** remettre `:-kesh_dev_root` → la vérification négative (b) échoue (rejouée en local au T8 avec le même script) | + +**Décompte** (recompté sur le tableau) : 9 lignes — 8 côté Rust, qui portent **8 fonctions de test** (7 +neuves : lignes 1, 2, 4, 7, 8, 9, 10 ; 1 existante modifiée : ligne 3a, `valeurs`), dans un fichier neuf +(`demarrage_mariadb.rs`) et deux existants (`configuration_transmise.rs`, `config.rs`) — plus 1 étape CI, et +un module d'aide de test neuf (`tests/common/binaire.rs`, aucun test ; `sortie_du_binaire_sans_configuration` +refactorée, non comptée : ce n'est pas un test) ; **24 mutations** (M1–M8, M12, M13, M16–M23, M27–M29, +M42–M44), toutes attendues rouges. **M43** et **M44** (validation P4, F6) gardent la seule propriété de +sécurité des AC 5 et 6 qu'aucune mutation ne gardait : ni mot de passe ni URL dans un journal. **Ligne 3a** (validation P3, constat du remédiateur) : la fiche unique +rangeait le resserrement de `VALEURS_COMPOSEES` sous le test `transmission` et M5 sous le test 1 ; or +`VALEURS_COMPOSEES` n'est lu que par `controle_valeur` (`configuration_transmise.rs:592-603`), appelé par +`controle_valeurs` (`:763`) et le test `valeurs` (`:1743`, famille (V)) — c'est `valeurs` que M5 rougit. + +Le test **(L)** (`garde_lecture_du_code`) et **(E)** (`env_example`) doivent rester verts **sans +modification** de leurs listes : la story n'ajoute aucune lecture d'environnement ni aucune variable. + +## Dev Notes + +### Ce que la story change, et ce qu'elle préserve + +- **Code** : deux modules de `kesh-api` — `config` (avertissements, fonctions d'indice, tests) et `main` + (un appel dans un message d'erreur). Aucune migration ; `kesh-db` non touché ; frontend non touché. +- **Préservé** : le réseau `kesh-network` ; `docker-compose.dev.yml` (pile de dev, mots de passe en dur, + port en loopback) ; `docker-compose.prod.yml` (aucune modification de cette story) ; la transmission des + variables de la 15-11a. + +### Pourquoi refuser par Compose et seulement avertir dans Kesh (C-15-13-2) + +Kesh ne voit pas `MARIADB_ROOT_PASSWORD` (non transmise, et ne doit pas l'être : C71 interdit `env_file`). +Il ne voit que `DATABASE_URL`. Refuser `kesh_dev` dans Kesh casserait la pile de développement +(`docker-compose.dev.yml:15` compose `mysql://kesh:kesh_dev@mariadb:3306/kesh`), le montage E2E local et +la documentation de test (`docs/testing.md`, `crates/*/README.md`, `CLAUDE.md`) — tous sur `kesh_dev`. +`KESH_TEST_MODE` ne permet pas de distinguer : `docker-compose.dev.yml` lie `0.0.0.0` et +`TestModeWithPublicBind` l'interdit. D'où le partage : + +- **le refus** est porté par Compose, là où vit le défaut : sans les variables, rien ne démarre, et le + message nomme la variable ; +- **l'avertissement** est porté par Kesh, pour les `.env` existants qui recopient les anciennes valeurs du + gabarit (`.env.example:336-339` jusqu'à la 0.12.1) — le port n'étant plus publié, le risque restant est + celui d'un conteneur du même réseau ; l'avertissement le dit. Le mot de passe **root**, que Kesh ne voit + pas, n'est couvert que par le contrôle de fin de mise à jour du manuel, qui se connecte **à la base** + avec les anciens mots de passe (AC 11 f, C-15-13-10) — un `grep` sur `docker compose config` ne lirait + que `.env`, et resterait muet quand `.env` porte une valeur neuve alors que la base garde l'ancienne. + +**Valeur gabarit (`<…>`)** — *rectifié en validation P3 (F-P3-3, C-15-13-17)* : la fiche unique la disait +« hors de portée de Kesh pour la même raison ». C'était faux pour le mot de passe **applicatif** : Kesh le +lit dans `DATABASE_URL`, et le prédicat `is_template_placeholder` existe. Il est donc **averti** (AC 5 a-bis), +pas refusé — même raison qu'en (a) : un mot de passe faible n'empêche pas de démarrer, et l'avertissement +suffit à le faire voir. La raison « Kesh ne voit pas root » ne vaut que pour **root** : un root gabarit reste +un angle mort, tracé par l'issue **#578** (§ *Angles morts*). + +### La mise à jour d'une installation existante — les cas, un par un + +Pour `docker-compose.yml` (MariaDB comprise). MariaDB **n'applique `MARIADB_*_PASSWORD` qu'à +l'initialisation d'un dossier de données vide** : ensuite, ces variables sont ignorées et seule la base +fait foi. C'est le piège central. *(Les cas propres à la sauvegarde — compose gardé sans montage, image +ancienne, restauration après un import raté — sont dans la 15-13b.)* + +| État de l'installation avant mise à jour | Après re-téléchargement du compose 0.13.0 | Ce que dit le manuel | +|---|---|---| +| `.env` copié d'un ancien gabarit : `MARIADB_PASSWORD=kesh_dev` actif | démarre ; port fermé ; **avertissement** Kesh au démarrage ; le contrôle de fin de mise à jour (connexion avec `kesh_dev` **acceptée**) le montre | changer les deux mots de passe (procédure AC 11 g) | +| `.env` sans les lignes (s'appuyait sur les défauts du compose) | **Compose refuse** : `required variable MARIADB_PASSWORD is missing a value: …` | écrire `MARIADB_ROOT_PASSWORD=kesh_dev_root` et `MARIADB_PASSWORD=kesh_dev` (les valeurs d'**origine**), démarrer, puis changer (AC 11 g) | +| `.env` avec des mots de passe propres posés **avant** la première initialisation | aucun changement | rien | +| base créée avec la chaîne gabarit **littérale** du manuel (``, ``, `admin-manual.tex:253-254`) | démarre ; **avertissement** Kesh (AC 5 a-bis) pour le mot de passe **applicatif** ; **aucun message** pour root (Kesh ne le voit pas) | **ne pas** toucher `.env` ; changer les deux mots de passe par la procédure (AC 11 b, 11 g) ; root gabarit : seule la relecture des chevrons de `.env` le montre | +| l'exploitant écrit une valeur **neuve** de `MARIADB_PASSWORD` dans `.env` sans `ALTER USER` | Kesh sort sur **1045** — avec l'**indice** de l'AC 6 | revenir à l'ancienne valeur, puis la procédure | +| l'exploitant écrit une valeur **neuve** de `MARIADB_ROOT_PASSWORD` dans `.env` sans `ALTER USER` (cas probable : Compose la nomme absente, et qui n'en avait jamais posé « en met une forte ») | **rien ne rougit** : Kesh n'utilise pas root, le healthcheck passe par le compte `healthcheck` de l'image (`docker-compose.yml:19`) ; root garde `kesh_dev_root` dans la base ; **la sauvegarde nocturne échoue** (le script lit `$MARIADB_ROOT_PASSWORD` dans le conteneur), dans un journal que personne ne lit | **détection** : le contrôle de fin de mise à jour (connexion root avec `kesh_dev_root` **acceptée**, avec la valeur de `.env` **refusée**) et le lancement du script à la main (AC 11 f) ; remède : revenir à l'ancienne valeur, puis la procédure | +| ligne `#MARIADB_PASSWORD=` du nouveau gabarit décommentée **sans valeur** | **Compose refuse** (forme `:?`, qui refuse le vide — d'où la scission de l'AC 10 a) | écrire la valeur d'origine | +| outil de l'hôte connecté à `127.0.0.1:3306` ou `IP:3306` (client SQL, script de sauvegarde) | refusé (port non publié) | `docker compose exec mariadb …` ; dépannage ponctuel : décommenter la ligne loopback | +| compose **gardé** (non re-téléchargé) | port toujours publié, défauts toujours là ; avertissement si `kesh_dev` ou gabarit | les gestes du paragraphe « Pour qui garde son fichier compose » (AC 11 f) | +| compose neuf, **image ancienne** (oubli du `pull`) | pas d'avertissement ni d'indice (image 0.12.x) | le manuel impose `docker compose pull` avant `up -d` (déjà écrit, 15-11a) | + +**Deux cas ne se signalent par aucun message** et ne sont vus que par un geste prescrit : le mot de passe +root neuf écrit sans `ALTER USER` (sauvegarde nocturne en échec) et le mot de passe **root** resté sur une +chaîne gabarit. Le manuel fait voir le premier par le **contrôle de fin de mise à jour** — connexion à la +base avec les anciens mots de passe, refus attendu — et par le **lancement manuel du script de sauvegarde** +(AC 11 f) ; le second, par la relecture des valeurs entre chevrons de `.env` que la procédure de mise à +jour prescrit déjà (« vérifier les placeholders », `admin-manual.tex:1746`) et que l'AC 11 b étend aux deux +mots de passe MariaDB. Toutes les autres pannes ont un message (Compose ou Kesh), et toute exposition +résiduelle un avertissement ou ce contrôle — dont, depuis la validation P3, le mot de passe **applicatif** +gabarit (AC 5 a-bis). *(La version de la fiche unique avant la P1 affirmait qu'« aucun de ces cas ne +casse en silence » : le tableau était incomplet — F3 et R-4 de la validation P1. Celle d'avant la P3 +rangeait le gabarit applicatif parmi les cas silencieux : F-P3-3.)* + +`docker-compose.prod.yml` (Synology, MariaDB externe) : cette story n'y change rien (le montage `./backup` +est de la 15-13b). La MariaDB de Package Center relève de DSM ; le manuel peut rappeler de vérifier qu'elle +n'écoute pas sur le réseau sans nécessité, sans recette (angle mort). + +### Le refus de Compose, mesuré en CI dans les deux sens (AC 4) + +`docker compose config -q` sans `.env` **échouera** sur `docker-compose.yml` dès l'AC 2 : l'étape actuelle +(`ci.yml:242-245`) rougirait. La corriger en lui donnant des valeurs ne suffit pas — il faut aussi +**exiger l'échec** sans elles, sinon le retour d'un défaut (`:-kesh_dev`) ne se verrait nulle part hors du +test Rust. Esquisse : + +```sh +MARIADB_ROOT_PASSWORD=ci MARIADB_PASSWORD=ci docker compose -f docker-compose.yml config -q +if env -u MARIADB_ROOT_PASSWORD -u MARIADB_PASSWORD \ + docker compose -f docker-compose.yml config -q 2> refus.txt; then + echo "docker-compose.yml doit refuser l'absence des mots de passe MariaDB"; exit 1 +fi +grep -qE 'MARIADB_(ROOT_)?PASSWORD' refus.txt +docker compose -f docker-compose.prod.yml config -q +``` + +Le runner n'a pas de `.env` à la racine (le dépôt n'en versionne pas : `git ls-files | grep '^\.env'` → +`.env.example` seul) ; vérifier en T0 qu'aucun `.env` local ne fausse la mesure locale (le déplacer le +temps du T8, ou lancer depuis une copie). + +### Forme du script de sauvegarde (AC 11 h) + +Le script quotidien (`admin-manual.tex:1446-1479`) passe la commande **entière** à `sh -c` entre +apostrophes, pour que `$MARIADB_ROOT_PASSWORD` soit lu **dans le conteneur** ; chaque ligne tient en 76 +caractères (AC 11 k). Forme de référence, à rejouer au T7 avant d'être écrite : + +```sh +COMPOSE_DIR="/chemin/du/compose" # répertoire de docker-compose.yml +cd "${COMPOSE_DIR}" + +docker compose exec -T mariadb sh -c \ + 'mariadb-dump --single-transaction --routines --triggers \ + --events --add-drop-database --databases kesh \ + -u root -p"$MARIADB_ROOT_PASSWORD"' \ + | gzip > "${BACKUP_FILE}" +``` + +La barre oblique **à l'intérieur** des apostrophes n'est pas lue par le shell de l'hôte : elle est +transmise à `sh`, qui lit la barre suivie d'un saut de ligne comme une continuation. La restauration +(`:1511-1512`) prend la même forme, l'entrée standard traversant `sh` : +`zcat .sql.gz | docker compose exec -T mariadb sh -c 'mariadb -u root -p"$MARIADB_ROOT_PASSWORD"'` +(coupée par `\` au manuel). `-T` est obligatoire dans les deux sens (pas de pseudo-terminal dans un tube). +Au T7, le conteneur jetable est lancé par `docker run` et non par Compose : la même commande intérieure +passe par `docker exec -i kesh-15-13-recette sh -c '…'` — c'est la commande **intérieure** qui est +validée, la forme `docker compose exec` restant celle du manuel. + +### Forme de la recette de changement (AC 11 g) + +Forme de référence, à rejouer au T7 (sur le conteneur jetable, par `docker exec -i`) avant d'être écrite — +la mesure fixe la forme finale ; les exigences, elles, sont celles de l'AC 11 g (C-15-13-23) : + +```sh +cd /chemin/du/compose +sudo docker compose stop kesh-api +NEW=$(openssl rand -hex 32) # jamais tapé : absent de l'historique +sudo docker compose exec -T mariadb \ + sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"' <`), `:328-339` | gabarit | résolu (AC 3) | +| `.github/workflows/ci.yml:55`, `:57`, `:64`, `:93` ; `release.yml:26`, `:28`, `:70`, `:94` | MariaDB de service du runner (`3306:3306`, `kesh_dev`, `kesh_root`) | **assumé** (runner éphémère, non distribué) | +| `.github/workflows/ci.yml:242-245` | validation des compose | résolu (AC 4) | +| `docs/ci.md:9-10`, `:12`, `:117` | doc de la CI (dont « 4 jobs », faux) | résolu (AC 4 d, T4) | +| `docs/ci.md:23`, `:30`, `:131`, `:164`, `:186`, `:210` | job `e2e` décrit, absent de `ci.yml` | **assumé, NON résolu** — défaut antérieur, hors sujet ; une phrase de renvoi sous `:10` vers **#577** (T4) | +| `crates/kesh-api/src/config.rs` — 34 URL `mysql://test:test@localhost:3306/test`, dont `:1485` | URL factices des tests | **assumé** (n'émettent pas l'avertissement : mot de passe `test`, ni publié ni gabarit) | +| `crates/kesh-api/src/config.rs:1398-1405` (`is_template_placeholder` et son doc-comment) | prédicat réemployé | résolu (AC 5 a-bis, d) | +| `crates/kesh-api/src/main.rs:82-87` | message d'échec de connexion | résolu (AC 6) | +| `crates/kesh-api/src/routes/reconciliation.rs:1129`, `:1136` ; `crates/kesh-db/src/retry.rs:57`, `:106` | constantes `MARIADB_SAVEPOINT_DOES_NOT_EXIST`, `MARIADB_DEADLOCK_ERROR_CODE` (grep `MARIADB_`, rejoué en remédiation P3 : absentes de l'inventaire jusque-là) | **assumé** — homonymes (codes d'erreur), forme citée par l'AC 6 b | +| `crates/kesh-api/tests/*_e2e.rs` (58 lignes `mysql://test:test@localhost:3306/test`, 2 `mysql://stub:stub@127.0.0.1:3306/stub`), `tests/health_endpoint.rs:36`, `tests/spa_resilience.rs:46` | URL de test | **assumé** (base de test, mot de passe non publié) | +| `crates/kesh-api/tests/configuration_transmise.rs:169-174` | `VALEURS_COMPOSEES` | résolu (AC 10 a, motif resserré, test `valeurs`) | +| `crates/kesh-api/tests/configuration_transmise.rs:190-193`, `:456` | `MARIADB` (variables d'hôte du service MariaDB), message de (E) | inchangé, voulu | +| `crates/kesh-api/tests/configuration_transmise.rs:541-564`, `:626`, `:2232-2254` | `Interpolation::Obligatoire` (définition, analyse, bras de `controle_valeur`) ; `sortie_du_binaire_sans_configuration` | résolu (AC 10 a ; T3) | +| `crates/kesh-api/src/middleware/auth.rs:234`, `:242` | URL factice `mysql://stub:stub@127.0.0.1:3306/stub` d'un test | **assumé** (test, `connect_lazy`) | +| `crates/kesh-api/README.md:169`, `:172`, `crates/kesh-db/README.md:34`, `:46`, `:53`, `:64`, `docs/testing.md:27`, `:29`, `:43`, `:53`, `:99`, `:100`, `:161`, `:171`, `:284`, `:302`, `scripts/test-fast.sh:33`, `scripts/regen-test-schema.sh:72`, `scripts/seed-dev-db.sql:8`, `:10`, `scripts/mariadb-init/01-dev-grants.sql:10`, `CLAUDE.md:179`, `:504`, `README.md:180`, `frontend/DEBUGGING-KF007.md:16` | recettes de dev et de test sur `kesh_dev` / `kesh_root` / `127.0.0.1:3306`, et démarrage de la pile de dev (`docker compose -f docker-compose.dev.yml up -d mariadb` : `crates/kesh-db/README.md:34`, `docs/testing.md:99`, `:161`) | **assumé** (base de dev jetable `docker-compose.dev.yml`, ou service MariaDB de la CI) | +| `init-demo.sh` (fichier entier, dont `:20`, `:23`, `:59`, `:69`, `:70-75`) | script de démonstration sur `kesh-mariadb` + `kesh_dev`, `DELETE` sans transaction | résolu **par retrait** (AC 12 b, C-15-13-13) | +| `.svelte-kit/ambient.d.ts:42-45`, `:240-243` (versionné) | noms `MARIADB_*` d'un environnement de build, sans valeur | **assumé** (fichier engendré, aucun secret) | +| `DOCKER_START.md:6`, `:12-13`, `:102`, `:146` | prérequis, phrase « Kesh refuse » sans `.env`, accès, volume | résolu (AC 12 a) | +| `DOCKER_START.md:134-137` | « Base de données ne s'initialise pas » → `down -v`, `up --build` | résolu (AC 12 a-bis) | +| `DOCKER_START.md:78` | « Arrêter et supprimer les données » (`down -v` explicite) | **assumé** — dit ce qu'il fait | +| `DOCKER_START.md:27`, `:37`, `:41`, `:86`, `:96`, `:132` | `docker compose up` (`:41` : `up -d` tire l'image publiée) | résolu par l'étape `.env` de l'AC 12 a (les deux mots de passe posés avant) | +| `docs/manual/fr/marketing-brochure.tex:542` + `marketing-brochure.pdf` | « `docker compose up -d`, une fois `KESH_JWT_SECRET` généré » | résolu (AC 11 m) | +| `docs/manual/fr/admin-manual.tex` — 21 lignes du `.tex` portent `docker compose up` (`grep -cE "docker[ -]compose( -f [^ ]+)? up"` → 21 : `:227`, `:275`, `:578`, … `:2247`), 21 occurrences aussi au PDF aplati | démarrage | **assumé** — `:227` est de la prose de l'étape 2 (« `docker compose up -d` la télécharge »), sans geste de démarrage ; les vingt autres sont postérieures à l'étape `.env` (`:229-270`), qui pose les mots de passe (AC 11 b), ou propres à Synology (`docker-compose.prod.yml`, sans MariaDB) — F2 de la validation P4 | +| `README.md:77`, `website/index.html:189` | `docker compose -f docker-compose.dev.yml up -d` | **assumé** — pile de dev ; aucun autre `docker compose up` sur `website/` (`grep -rniE "docker[ -]compose" website` : trois mentions sans commande) | +| `.env.example:19`, `:297` | commentaires « `docker compose up -d` après une modification » ; avertissement de Compose sur `$` | **assumé** — le gabarit pose les mots de passe dans son bloc MariaDB (AC 3), qu'un `up` suit | +| `docker-compose.yml:31-35`, `:49` (commentaires : l'image publiée est tirée ; `up -d` après une modification de `.env`) ; `docker-compose.prod.yml:12`, `:34`, `:53`, `:69` | `docker compose up` en commentaire | **assumé** — `docker-compose.yml` : le refus nommé de Compose dit ce qui manque ; `docker-compose.prod.yml` : sans MariaDB | +| `docs/kesh-specifications.txt:579`, `:1603`, `:1638`, `:1972`, `:2002`, `:2334` (**NFR-DEPLOY-1**, « une seule commande `docker-compose up` »), `:2629`, `:3153` | spécification historique | **assumé** — document de spécification, non réécrit ; NFR-DEPLOY-1 n'est plus vrai depuis la 15-11a (`.env` obligatoire) et l'est encore moins ici : signalé à l'orchestrateur | +| `admin-manual.tex:171`, `:176`, `:2362` | ports | résolu (AC 11 a) | +| `admin-manual.tex:240-259`, `:1746` | étape `.env`, placeholders | résolu (AC 11 b) | +| `admin-manual.tex:546` | `base64` → `hex` | résolu (AC 11 c) | +| `admin-manual.tex:689-694` | note MariaDB de `sec:env-vars` | résolu (AC 11 d) | +| `admin-manual.tex:1780-1786` | contrôle des placeholders muet quand Compose refuse | résolu (AC 11 f) | +| `admin-manual.tex:1460`, `:1469`, `:1511-1512`, `:2216` | `exec db`, `-p"${…}"` | résolu (AC 11 h) | +| `admin-manual.tex:1757-1841`, dont l'encadré `:1761-1776` (hors `:1773`, 15-13b) | mise à jour 0.13.0 — parties MariaDB | résolu (AC 11 f, g) | +| `admin-manual.tex:1793` (« Pour qui garde son fichier compose : deux gestes. » et « Sous `environment:` du service `kesh-api` : ») | décompte des gestes faux | résolu (AC 11 f, F-P3-1) | +| `admin-manual.tex:1747-1748` (`pull kesh-api`, `up -d kesh-api` de la procédure standard) | le service `mariadb` est-il recréé par un `up -d` nommant `kesh-api` ? | **assumé** — le § 0.13.0 prescrit `docker compose up -d` sans nom de service (déjà écrit) ; l'effet de la forme nommée est relevé au T7 | +| `admin-manual.tex:2192-2199` | dépannage | résolu (AC 11 j) ; `:2189` (`logs kesh`) laissé à la 15-7b3 | +| `admin-manual.tex:1578-1581` (pré-script Hyper Backup : `exec -T mariadb` et `grep MARIADB_ROOT_PASSWORD .env`) et `:573` (aperçu Container Manager listant `mariadb`) | **section Synology, dont le compose (`docker-compose.prod.yml`) n'a pas de service `mariadb`** | **assumé, NON résolu** — défaut antérieur à la story, **#575** (P3). Aggravé par l'AC 3 b : avec `#MARIADB_ROOT_PASSWORD=` commentée **et** une ligne active, `grep … \| cut -d= -f2` rend deux lignes — à joindre à #575 | +| `admin-manual.tex:1744` (« volume `kesh_db_data` ») et `:1422` (`/var/lib/docker/volumes/kesh_db_data`) | nom de volume faux (réel : `_kesh-mariadb-data`) | **assumé, NON résolu** — **#575** | +| `CHANGELOG.md:46` — quatre phrases (« deux gestes », « Kesh refuse de démarrer », « défaut sans avertissement », « `.env` copié du gabarit ») | rendues fausses | résolu (AC 13 c) | +| `CHANGELOG.md:48` | « après `git pull`, `docker compose up -d` tire … » (clone) | **assumé** — même section `[0.13.0]` que l'entrée #551, qui dit le refus de Compose et les mots de passe à poser (AC 13 a) | +| `CHANGELOG.md:52` | contrôle des placeholders « doit rester muette » | résolu (AC 13 d, F-P3-2) | +| `CHANGELOG.md` — rubrique **Retiré** de `[0.13.0]`, ligne `init-demo.sh` (créée par l'AC 13 b) | mention du retrait | résolu, **voulue** (exclue du grep de l'AC 12 b) | +| `CHANGELOG.md:653`, `:655` | entrées de versions antérieures (port 3000, `cargo run`) | **assumé** — historique, ne se réécrit pas | +| `docker-compose.yml` `kesh-api` `ports: "80:80"` (toutes interfaces) | port de l'application | **assumé** — hors #551 (accès LAN voulu sans reverse proxy) ; non traité | +| `admin-manual.tex:959` (`CREATE USER 'kesh'@'%' IDENTIFIED BY 'mot_de_passe_fort';`, initialisation manuelle hors Docker) | littéral imprimé dans le PDF distribué (F3 de la validation P4) | **assumé** — installation hors Docker, hors #551 ; non couvert par la liste fermée (§ *Angles morts*) | + +**Décompte** : 48 lignes (recompté par `awk` sur le tableau — `grep -c '^| '` rend 49, en-tête compris ; +le séparateur commence par `|---` et n'est pas compté). 47 à la validation P4, plus la ligne `:959` (F3) ; les +sites de F1 (`docs/testing.md:171`, `:284`, `:302`, `CLAUDE.md:179`) enrichissent une ligne existante. + +### Angles morts assumés (écrits, non traités) + +- **Mot de passe root resté au gabarit** (`MARIADB_ROOT_PASSWORD=` recopié de l'étape 3) + : accepté par Compose et par MariaDB ; Kesh ne voit pas root (C71) — aucun avertissement possible. Seule + la relecture des chevrons de `.env` prescrite par la procédure de mise à jour (AC 11 b) le montre. Le + mot de passe **applicatif** gabarit, lui, est averti (AC 5 a-bis, C-15-13-17). Hérité de la 15-11a + (F4-5), dont le propriétaire était « #551 » : #551 traite le **défaut publié** ; le root gabarit est + tracé par l'issue **#578** (ouverte le 2026-10-09 ; § *Issue Tracking Rule* : pas de suivi local). +- **Mot de passe root** — `kesh_dev_root` recopié d'un ancien `.env`, ou valeur neuve écrite dans `.env` + sans `ALTER USER` : invisible de Kesh et du healthcheck ; seuls le contrôle de fin de mise à jour du + manuel (connexion à la base) et le lancement manuel du script de sauvegarde le montrent (AC 11 f, + C-15-13-10). Le CHANGELOG le dit (AC 13 a). +- **Compose de Synology Container Manager** : `${VAR:?msg}` non mesurable depuis le poste ; la recette de + la v0.13.0 sur le NAS le confirmera — mais le NAS de Guy suit l'installation Synology + (`docker-compose.prod.yml`, sans MariaDB) : cette story ne le concerne pas. +- **`docker-compose.dev.yml`** : non contraint par le test (inchangé depuis la 15-11a) ; #554 ouverte pour + son mot de passe admin. +- **Portée de la liste fermée `MOTS_DE_PASSE_PUBLIES`** (F3 de la validation P4) : elle ne couvre que les + valeurs que Kesh a lui-même **distribuées comme défaut** d'une installation (`docker-compose.yml`, + `.env.example`). D'autres littéraux du dépôt sont publiés au même titre sans y entrer : `kesh_root` + (MariaDB de service de la CI, `ci.yml:64`, `:93`, `release.yml:70`, `:94` ; `frontend/DEBUGGING-KF007.md:16`) + — runner éphémère, jamais un défaut d'installation ; `mot_de_passe_fort` (`admin-manual.tex:959`, + initialisation hors Docker) — exemple d'une procédure manuelle, que Kesh n'a jamais posé de lui-même. + Choix écrit, non traité. + +### Règle de découpage + +**Un crate, deux modules de code** — `kesh-api::config` et `kesh-api::main` —, plus des tests +(`configuration_transmise.rs` étendu, `demarrage_mariadb.rs` neuf, module d'aide `tests/common/binaire.rs`) +et des fichiers de configuration, de CI et de documentation (`docker-compose.yml`, `.env.example`, +`ci.yml`, `docs/ci.md`, `init-demo.sh` — **supprimé** —, manuel et brochure + PDF, `DOCKER_START.md`, +`CHANGELOG.md`). Convention de décompte (celle de la fiche unique) : le seuil compte les **modules de +code** au sens du `CLAUDE.md` ; tests, configuration, CI, scripts et documentation n'y entrent pas. Seuil +(« plus de 5 ») loin d'être atteint. + +### Références + +- Fiche index `15-13-mariadb-et-sauvegarde.md` (découpage, recompte, Change Log des validations P1 à P3) ; + fiche unique complète au commit **8a9bcd27**. +- Issues #551, #577 (renvoi de `docs/ci.md`), #575 (section Synology, hors story), #554 ; #578 (root + gabarit). +- Rapports de validation (non versionnés) : `target/gate-logs/15-13-p{1,2,3}-{R,F}.md` ; prompts + `15-13-validate-prompt-p{1,2,3}.md`. +- `_bmad-output/implementation-artifacts/15-11a-compose-transmet-la-configuration.md` — § *Angles morts* + (`#551`, F4-5), AC 12 (conventions du manuel : 76/70 caractères, `make fr`, contrôle aplati, + `Overfull` des dix tableaux), AC 16 (`is_template_placeholder`, #557). +- `crates/kesh-api/tests/configuration_transmise.rs` — `VALEURS_COMPOSEES` (`:169-174`), `controle_valeur` + (`:572-663`), test `valeurs` (`:1743`), `sortie_du_binaire_sans_configuration` (`:2232`). +- Documentation Docker : *Packet filtering and firewalls* — « Docker and ufw » (ports publiés hors UFW) ; + image officielle `mariadb` — variables `MARIADB_*` lues à l'initialisation d'un datadir vide seulement. +- CLAUDE.md : § *Test Locally First* (E2E au dernier commit de code, D7), § *Propagation post-patch*, § + *Recompter ses propres comptes rendus*, § *Le prompt d'une passe doit NOMMER le manuel*, § *Issue + Tracking Rule* (mots-clés sur la PR). + +## Dev Agent Record + +### Agent Model Used + +Opus 5.5 (agent de développement, en autonomie — Epic 15), worktree `kesh-15-13a`, branche +`story/15-13a-mariadb-non-publiee`, base `200f5e79` + planification `08816686`. + +### Debug Log References + +Journaux non versionnés sous `target/gate-logs/` du worktree : `15-13a-t0/` (référence `make` d'avant : +`.log`, aplatis), `15-13a-t5/` (aplatis du neuf), `15-13a-mut-M*.txt` (23 mutations Rust), +`15-13a-gate-backend.txt`, `15-13a-front-*.txt`, `15-13a-e2e.txt`, `15-13a-e2e-backend.log`. +Mesures T0/T7 : journaux du T7 dans le répertoire de travail de la session (non versionnés), résumés +ci-dessous. + +### Completion Notes List + +**T0 — mesures** (détail au Change Log, entrée « T0 du développement »). +- Docker Compose **2.40.3**. Message `:?` : formes c1 et c2 lisibles, accents restitués → **c1 retenue** + (valeurs non citées). Sans les variables : `config`, `pull`, `up -d`, `up -d kesh-api` refusent (code 1) ; + `ps`, `logs`, `exec`, `stop`, `down` passent (code 0) — **écart à l'AC 11 f**, écrit selon la mesure + (C-15-13a-1). Variable nommée **non déterministe** (12 lancements : `MARIADB_ROOT_PASSWORD` 5, + `MARIADB_PASSWORD` 3, `DATABASE_URL` 4) — **écart à l'AC 11 j**, écrit « une à la fois, pas toujours la + même ». +- `sqlx` : mauvais mot de passe → `Error::Database`, numéro **1045** joignable par + `try_downcast_ref::()`, immédiat ; base sans droit → 1044. Pas de repli textuel. +- Test 8 : `kesh-15-13.invalid` échoue en 16 ms (retenu) ; `127.0.0.1:1` en 10 000 ms. +- `docker compose config` : forme développée des ports (`- mode: ingress` / `host_ip` / `target` / + `published: "…"`) ; aucune entrée sous `mariadb` après la story. + +**T7 — recette rejouée** (MariaDB 10.11.16 jetable ; d'abord sur un projet Compose jetable +`kesh1513a-t0`, puis — le démon Docker ayant figé ce conteneur lors d'une recréation sous une charge de 35, +`inspect`, `exec` et `kill` bloqués, aucun autre conteneur touché — sur des conteneurs `docker run` +jetables `kesh1513a-t7a/b`, réseau et volume propres ; tout supprimé à la fin, `kesh-mariadb-dev` jamais +touché) : +- comptes de l'image : `root@localhost`, `root@%`, `kesh@%`, `healthcheck@{127.0.0.1,::1,localhost}`, + `mariadb.sys@localhost` ; `@@skip_name_resolve = 1` ; +- `CURRENT_USER()` : socket → `root@localhost` ; `--protocol=TCP -h 127.0.0.1` → `root@%` ; + `-h mariadb` (alias réseau) → `root@%` ; `kesh` en TCP → `kesh@%` ; +- **socket avec un mauvais mot de passe root → 1045** : pas d'authentification `unix_socket`, d'où deux + contrôles de root au manuel (socket et TCP) ; +- recette : `ALTER USER IF EXISTS` pour les deux root et `ALTER USER kesh`, en **un** document en ligne par + `sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"'` → code 0 ; `ps -eo args` de l'hôte pendant + l'exécution : **aucune** ligne avec les nouveaux mots de passe hors le `grep` de contrôle ; après + l'`ALTER`, avant recréation, la connexion par l'environnement du conteneur (ancien root) → **1045** + (d'où un seul lot) ; `healthcheck.sh --connect --innodb_initialized` → 0 ; +- après recréation avec les nouvelles valeurs : contrôle de fin **dans la forme exacte du manuel** + (coupée par `\`, y compris dans `sh -c '…'`) — trois anciens → `ERROR 1045`, trois de l'environnement → + `root@localhost`, `root@%`, `kesh@%` ; +- sauvegarde puis restauration dans la forme du manuel (`sh -c`, `-T`) : table témoin créée, dumpée + (présente dans le dump), supprimée, restaurée, relue (`1 avant-dump`) ; dump avec un mot de passe faux → + code 2 sous `pipefail` (le script s'arrête) ; +- `up -d` après changement de `.env` : Compose décide **Recreate** pour `mariadb` ; `up -d kesh-api + --dry-run` aussi (la forme nommée recrée la dépendance dont l'environnement a changé) ; +- `down` sans `.env` sur un projet en marche : code 0. + +**Choix tranchés au fil du développement.** +- Forme du message : c1 (non citée). CHANGELOG `:52` (AC 13 d) : commande `config -q && echo 'compose + lisible'` **recopiée** avant le `grep`, plutôt qu'un simple renvoi (l'entrée #557 recopiait déjà le + `grep` : le renvoi seul l'aurait laissé piégeux). CHANGELOG `:46` : « les gestes, fichier par fichier » + (**renvoi sans nombre**, insensible à l'ordre de merge 15-13a/15-13b). +- Étape CI renforcée (C-15-13a-2) : M27 tel qu'écrit **survivait** à l'esquisse de la fiche. +- Manuel : le contrôle de fin couvre `root@localhost` (socket) **et** `root@%` (TCP), mesure T7 ; la + recette change les trois comptes en un lot (après l'`ALTER` de root, une seconde connexion par + l'ancienne valeur échoue) ; `ALTER USER IF EXISTS` pour root (comptes listés d'abord). +- Script de sauvegarde : `COMPOSE_DIR="/opt/kesh"` (répertoire d'installation de l'étape 1 du manuel) ; + encadré « lancer une fois à la main » ajouté sous la crontab. +- `DOCKER_START.md:12-13` réécrit (Compose d'abord), `:146` → `kesh-mariadb-data` / + `_kesh-mariadb-data`. +- Contrôles de l'AC 11 l validés sur l'aplati neuf : apostrophe et double tiret sortent **droits** en + `lstlisting` (`MARIADB_ROOT_PASSWORD"'` ×3, `--protocol=TCP` ×5 dont un `\texttt{-{}-…}` de prose) ; + aucun ajustement de motif. + +**Mutations (T8)** — 24 jouées, **24 rouges**, chacune sur le test visé et pour le motif voulu (aucun +échec de compilation) ; fichier restauré par `git checkout` puis `touch` (banc scripté) : + +| Mut. | Test rouge | +|---|---| +| M1, M2, M3, M4, M28 | `configuration_transmise::mariadb` | +| M5 | `configuration_transmise::valeurs` | +| M6, M7, M8 | `configuration_transmise::mots_de_passe_publies` | +| M12, M13, M29 | `configuration_transmise::s_service_mariadb` | +| M16, M17, M18, M19, M42, M43 | `config::mot_de_passe_base_tests::from_env_warns_on_published_database_password` (M43 : « le journal cite « kesh_dev » ») | +| M20 | `demarrage_mariadb::mot_de_passe_publie_avertit_au_demarrage` | +| M21, M22 | `config::mot_de_passe_base_tests::indice_connexion_refusee` | +| M23, M44 | `demarrage_mariadb::mauvais_mot_de_passe_donne_l_indice` (M44 : « ne doit pas citer le mot de passe ») | +| M27 | étape CI « Validate compose files », rejouée sous `bash -eo pipefail` sur une copie : rouge (après renforcement, C-15-13a-2) ; le retour du seul défaut `MARIADB_PASSWORD` du service reste vert à dessein (la `DATABASE_URL` l'exige encore ; test Rust `mariadb` rouge) | + +**Gates (T10)**, au commit `e38d96cf` (dernier commit de code : `38428924`, les deux suivants sont de la +documentation), bases `kesh_1513a` / `kesh_e2e_1513a` remises à zéro (`DROP`/`CREATE`, migrations, seed) +avant chacun : +- `scripts/test-fast.sh` (fmt + clippy `-D warnings` + nextest) : **2980 / 2980**, 4 ignorés (2973 sur + `main` + 7 fonctions neuves) ; +- frontend : `npm run check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` vert, + Vitest **1095 / 1095**, `npm run build` vert (frontend non touché) ; +- E2E complet (backend `target/debug/kesh-api` du worktree sur le port 3017, base `kesh_e2e_1513a`, + `KESH_TEST_MODE=true` des deux côtés, `KESH_COOKIE_SECURE=false`, SMTP factices, répertoires + `target/e2e/{inbox,documents}` ; `/health` → `smtpConfigured:true` ; lancé à 05:52 UTC) : **245 passés, + 9 échoués**, 19 ignorés, 10,5 min — les 9 sont la liste attendue de `docs/testing.md` : KF-029 ×7 + (`mode-expert:26`, `:41`, `onboarding-path-b:65`, `:92`, `onboarding:57`, `:77`, `:150`) et KF-045 ×2 + (`invoices:415`, `:439`, avant midi UTC) ; aucun hors liste, aucune pollution. Le journal de ce backend + porte une fois l'avertissement « mot de passe publié » (`DATABASE_URL` sur `kesh_dev`) : AC 5 observé en + conditions réelles. Backend arrêté par son PID ; +- manuels : `make fr` ; `Overfull \hbox` du manuel d'administration **55 → 55**, aucun nouveau (comparés + par texte de ligne source au `.log` d'avant ; 7 nouveaux au premier passage, résorbés par `sloppypar`) ; + brochure 4 → 4 ; aucun renvoi indéfini ; `user-manual.pdf` restauré (non touché, seuls des numéros de + page de la table différaient). +- Contrôles aplatis (AC 11 l, m) — avant → après : `openssl rand -hex 32 # → mot de passe MariaDB` 0 → 1 ; + `MARIADB_ROOT_PASSWORD"'` 0 → 3 ; `--protocol=TCP` 0 → 5 ; `CURRENT_USER()` 0 → 6 ; `compose lisible` + 0 → 2 ; `exec (-T )?db` 3 → **0** ; `deux gestes` 1 → **0** ; brochure `MARIADB_ROOT_PASSWORD` 0 → 1. +- Étape CI : rejouée localement (nominal vert, M27 rouge) ; le job `docker-build` lui-même n'a pas tourné. + +**T9 — propagation et inventaire** : greps (1) à (4) rejoués à `e38d96cf`. Aucun site hors inventaire. +Sites **neufs** créés par la story, voulus : constantes et témoins de test (`config.rs` +`MOTS_DE_PASSE_PUBLIES` et test 7, `configuration_transmise.rs` (M), `demarrage_mariadb.rs:25`), les +commentaires `3306` de `docker-compose.yml` (forme loopback à décommenter), `docs/ci.md` (`MARIADB_` de la +validation), les mentions du manuel et du CHANGELOG (anciens défauts écrits **au manuel seulement**, +contrôle de fin). `admin-manual.tex` porte désormais 23 lignes `docker compose up` (21 avant) : les deux +neuves sont dans le § 0.13.0 et le § *Changer un mot de passe MariaDB*, après la pose des mots de passe. +`git grep -n init-demo -- . ':(exclude)_bmad-output' ':(exclude)CHANGELOG.md'` → vide. + +**À l'orchestrateur.** +- Conteneur figé du T7 : supprimé ensuite (`docker rm -f` a fini par aboutir) ; plus aucune ressource + `kesh1513a` (conteneur, volume, réseau). Le démon a été lent tout le long (création d'un conteneur : 49 s). +- `docs/kesh-specifications.txt` (NFR-DEPLOY-1, « une seule commande `docker-compose up` ») : faux depuis la + 15-11a, davantage ici — signalé, non réécrit (inventaire). +- `CLAUDE.md` § E2E annonce encore un smoke E2E en CI : #577. + +**Revue de code P1 — remédiation** (agent remédiateur, Opus 5.5, en autonomie). Aucune ligne de code Rust +de production touchée (`git diff --name-only | grep '\.rs$'` → le seul fichier de test +`configuration_transmise.rs`). +- **E-1 + A-1 (MEDIUM)** — distinction *installation neuve* (valeurs neuves, `openssl rand -hex 32`) / + *base déjà créée* (valeur d'origine : `kesh_dev_root`/`kesh_dev`, ou la chaîne gabarit recopiée, jamais + une neuve, puis `ALTER USER`) écrite à : `DOCKER_START.md` étape 1 (encadré « Base déjà créée » avant le + bloc de génération, bloc qualifié « installation neuve ») et dépannage « Compose refuse » ; manuel, + Étape 3 d'installation (`générez` qualifié « installation neuve », anciens défauts ajoutés à la phrase + « base déjà créée »), étape 3 de la procédure de mise à jour standard (« ne remplacez pas ces deux + valeurs »), fin de « Vérifier les placeholders » (« ne remplacez pas ces chevrons »), exemple `:?` + (A-6) ; CHANGELOG #551 (gabarit recopié, installation neuve, chevrons à ne pas remplacer). Grep par la + valeur (`openssl rand`, `générez`, `choisissez`, `MARIADB_ROOT_PASSWORD`, `MARIADB_PASSWORD`, formes + LaTeX comprises) sur tout le dépôt hors `_bmad-output` : sites restants triés comme justes — + `.env.example` (déjà en deux cas), compose (`:?` porte la mise en garde), `README.md:81` (compose de + dev), `crates/kesh-api/README.md` (JWT seul), manuel `:553-554` (Synology, base de l'exploitant, #575), + `:2295` (dépannage, déjà distingué), brochure (instance locale neuve), CI (factices). +- **LOW** : B-L1/E-3=A-5 recette `ALTER USER` refondue (C-15-13a-3), **rejouée** sur un projet Compose + jetable `kesh1513ap1` (MariaDB 10.11, `MARIADB_USER=compta`, puis `down -v`) — nominal rc 0 et ligne + témoin, anciens mots de passe → 1045 (socket et TCP), neufs → `root@localhost`, `root@%`, `compta@%` ; + reprise sans recréation → rc 1 (1045), pas de ligne témoin ; compte absent → rc 1, ERROR 1396, root et + compte inchangés. Non remesuré : l'absence des mots de passe dans `ps` (mécanisme inchangé, entrée + standard). E-2 (« omettez les lignes de l'autre » → génération, `echo`, `ALTER USER`, ligne de `.env` + intacte) ; E-4 (`mot_de_passe_fort` → ``) ; B-L4 (`cd /opt/kesh` de la + restauration renvoyé à `COMPOSE_DIR` ; volume `kesh_db_data` **laissé à #575**, C-15-13a-4) ; B-L5 + (tableau des ports : « Voir ci-dessous », encadré complété pour `docker-compose.prod.yml` ; référence + des ports : « Non publié (`docker-compose.yml`) ») ; A-2 (`logs` qualifié « mesuré avec Docker Compose + 2.40 ») ; A-3 (commentaire CI : rouge pour `MARIADB_ROOT_PASSWORD`, test Rust `mariadb` pour + `MARIADB_PASSWORD`) ; B-L2 (`refus.txt` sous `$RUNNER_TEMP`) ; B-L3 (`contenus[2]` → recherche par + nom avec `expect` de montage) ; B-L6=E-5=A-4 (CHANGELOG : ligne vide double retirée, ligne vide avant + `---`). +- **Gate ciblé** (base `kesh_1513a` remise à zéro par `DROP`/`CREATE`, migrations, seed) : `cargo fmt + --check` vert ; `cargo clippy --workspace --all-targets -D warnings` vert ; `cargo nextest run -p + kesh-api -E 'binary(configuration_transmise) | binary(demarrage_mariadb)'` **27/27** (25 + 2). Étape CI + « Validate compose files » rejouée localement depuis `ci.yml` (`bash -eo pipefail`, `RUNNER_TEMP` posé) : + nominal rc 0, aucun `refus.txt` à la racine ; mutation « défaut `:-kesh_dev_root` revenu » → rc 1 + (« doit refuser l'absence de MARIADB_ROOT_PASSWORD »). Gate complet et E2E **non rejoués** (aucun code + de production touché ; à refaire au dernier commit de code selon D7 si la boucle continue). +- **Manuel** : `make admin` ; `Overfull \hbox` **55 → 55**, ensemble identique (comparé par texte de + ligne ; un 56e né de la référence des ports, résorbé) ; aucun renvoi indéfini. Aplati (`pdftotext | + tr`) : « ne remplacez pas ces deux valeurs » 1, « CHANGÉS DANS LA BASE » 3, « printenv MARIADB_USER » 1, + « sur une installation neuve, générez chacun » 1, « Exemple abrégé » 1, « Contrai- rement aux deux + variables précédentes, ne remplacez pas ces chevrons » 1 (césure), `mot_de_passe_fort` 0, « omettez + les lignes de l'autre » 0. Brochure non touchée. + +**Clôture** (agent de clôture, Opus 5.5, en autonomie). `origin/main` toujours à `200f5e79` après +`git fetch` : base de la branche, **rebase sans objet**, aucun conflit. Gate **complet** sur la tête +`7e471fd4` (dernier commit de code : `7faa16bb`, remédiation P1 — `ci.yml` et un test ; D7), bases +`kesh_1513a` / `kesh_e2e_1513a` remises à zéro (`DROP`/`CREATE`, migrations, seed) avant le backend et +avant l'E2E : +- `scripts/test-fast.sh` (fmt + clippy `-D warnings` + nextest) : **2980 / 2980**, 4 ignorés ; +- frontend : `npm run check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` vert, + Vitest **1095 / 1095** (112 fichiers), `npm run build` vert ; +- E2E complet (binaire `target/debug/kesh-api` du worktree, port 3017, base `kesh_e2e_1513a`, secrets + `KESH_JWT_SECRET` / `KESH_ADMIN_PASSWORD` tirés par `openssl rand`, `KESH_TEST_MODE=true` des deux + côtés, `KESH_COOKIE_SECURE=false`, SMTP factices, répertoires `target/e2e/{inbox,documents}` ; + `/health` → `smtpConfigured:true` ; de 06:29 à 06:39 UTC) : **245 passés, 9 échoués**, 19 ignorés, + 10,0 min — KF-029 ×7 (`mode-expert:26`, `:41`, `onboarding-path-b:65`, `:92`, `onboarding:57`, `:77`, + `:150`) et KF-045 ×2 (`invoices:415`, `:439`, avant midi UTC) ; aucun hors liste, pas de « huitième + variable » ce run ; aucun `Permission denied` au journal du backend, arrêté par son PID ; +- étape CI « Validate compose files » rejouée localement depuis `ci.yml` (`bash -eo pipefail`, + `RUNNER_TEMP` posé, mots de passe MariaDB retirés de l'environnement, pas de `.env` au worktree) : + **code 0**, refus de Compose affiché pour `MARIADB_ROOT_PASSWORD`, aucun `refus.txt` à la racine ; + `docker-compose.prod.yml` lisible. + +**Intégration sur `dc4bc58b`** (agent de clôture, Opus 5.5). La PR #582 était en conflit après le +merge de la 15-6b (#580) et de la 15-12b (#581). Rebase sur `origin/main` `dc4bc58b`, conflits résolus +en union : +- `epic-15-choix-autonomes.md` : entrées de main (C-15-6b-1 à 3, C124 à C132, C-15-12b-1 à 4) puis les + miennes (C-15-13-1 à 26) ; aucun doublon (`uniq -d` sur les titres → vide) ; +- `sprint-status.yaml` (deux commits) : lignes 15-12b de main (`done`) gardées, les lignes 15-13, 15-13a + et 15-13b ajoutées ; ma ligne `last_updated` renumérotée **(39)**, après les (36) à (38) de main ; +- `admin-manual.pdf` : conflit binaire, le `.tex` ayant fusionné sans conflit (main y ajoute l'ordre + de clôture des exercices) → PDF **régénéré** (`make admin`) : `Overfull \hbox` 55 (comme avant), + aucun renvoi indéfini ; aplati (`pdftotext | tr`) : les contrôles de la story inchangés + (`openssl rand -hex 32 # → mot de passe MariaDB` 1, `MARIADB_ROOT_PASSWORD"'` 3, `--protocol=TCP` 5, + `CURRENT_USER()` 6, `compose lisible` 2, « ne remplacez pas ces deux valeurs » 1, « CHANGÉS DANS LA + BASE » 3, `exec (-T )?db` 0) et les ajouts de main présents (« Exercices clôturés dans le + désordre » 2, « du plus ancien vers le plus récent » 1). Brochure non touchée par main ; +- `CHANGELOG.md` fusionné sans conflit : `[0.13.0]` garde une rubrique de chaque titre (Ajouté, + Modifié, Retiré, Corrigé, Sécurité). Aucun conflit de code. + +Gate **complet** sur l'état rebasé, bases `kesh_1513a` / `kesh_e2e_1513a` remises à zéro avant le +backend et avant l'E2E : +- `scripts/test-fast.sh` : **3033 / 3033**, 4 ignorés ; +- frontend : `npm run check` 0 erreur (27 avertissements préexistants), `lint-i18n-ownership` vert, + Vitest **1139 / 1139** (114 fichiers), `npm run build` vert ; +- E2E complet (même montage que la clôture, port 3017, secrets tirés par `openssl rand`, + `KESH_TEST_MODE=true` des deux côtés, `/health` → `smtpConfigured:true` ; de 08:11 à 08:23 UTC) : + **244 passés, 10 échoués**, 19 ignorés — KF-029 ×7, KF-045 ×2 (`invoices:415`, `:439`, avant midi + UTC) et `sidebar-navigation:75` (KF-052, #424, ouverte, dans la liste de `docs/testing.md`), + **rejoué seul : vert** ; aucun hors liste, aucun `Permission denied` au journal ; backend arrêté par + son PID ; +- étape CI « Validate compose files » rejouée localement (`bash -eo pipefail`, `RUNNER_TEMP`, sans + `.env`) : code 0, refus affiché, aucun `refus.txt` à la racine. + +### File List + +- `docker-compose.yml` — port de `mariadb` retiré (commentaire, forme loopback), trois `:?`. +- `.env.example` — bloc MariaDB réécrit, mots de passe commentés ; commentaire `DATABASE_URL` (hex). +- `crates/kesh-api/Cargo.toml` — `url`, `percent-encoding` en dépendances directes (déjà au `Cargo.lock`). +- `crates/kesh-api/src/config.rs` — `MOTS_DE_PASSE_PUBLIES`, avertissements, `numero_erreur_mariadb`, + `indice_connexion`, doc d'`is_template_placeholder` ; tests 7 et 9. +- `crates/kesh-api/src/main.rs` — indice dans le message d'échec de connexion. +- `crates/kesh-api/tests/configuration_transmise.rs` — `service(source, nom)`, `ports`, scission + d'`Interpolation::Obligatoire`, `VALEURS_COMPOSEES` resserré, contrôle (M), tests 1, 2, 4, en-tête ; + `sortie_du_binaire_sans_configuration` sur `lancer_binaire`. +- `crates/kesh-api/tests/common/binaire.rs` (neuf) — `lancer_binaire`, `texte`. +- `crates/kesh-api/tests/demarrage_mariadb.rs` (neuf) — tests 8 et 10. +- `.github/workflows/ci.yml` — étape « Validate compose files ». +- `docs/ci.md` — 3 jobs, renvoi à #577, deux sens du contrôle. +- `docs/manual/fr/admin-manual.tex` + `.pdf` — AC 11 a–d, f–h, j, k. +- `docs/manual/fr/marketing-brochure.tex` + `.pdf` — AC 11 m. +- `DOCKER_START.md` — AC 12 a, a-bis ; revue P1 (base déjà créée / installation neuve, `logs`). +- `init-demo.sh` — **supprimé** (AC 12 b). +- `CHANGELOG.md` — `[0.13.0]` : Sécurité #551, Retiré, propagation `:46` et `:52`. +- `_bmad-output/implementation-artifacts/sprint-status.yaml`, `epic-15-choix-autonomes.md` (C-15-13a-1, -2), + cette fiche. + +## Change Log + +- 2026-10-09 — **Création par découpage de la 15-13, et remédiation de la validation P3** (agent remédiateur, + Opus 5.5, en autonomie). Historique de la fiche unique (spécification, validations P1 et P2) : Change Log + de la fiche index. **Trend** de la fiche unique : P1 (Opus 5.5 ×2) 8 MEDIUM distincts → P2 (Sonnet ×2) 6 + → P3 (Opus 5.5 ×2 : R 2 MEDIUM / 8 LOW, F 3 MEDIUM / 8 LOW, aucun doublon) **5 MEDIUM distincts** ; 0 + CRITICAL/HIGH aux trois passes. **Signal D5 de recyclage** levé deux passes de suite (P2 : deux MEDIUM nés + de la P1 ; P3 : R3-1, R3-2 et F-P3-2 nés de la P2) → **découpage** décidé par l'orchestrateur + (C-15-13-16) selon la couture #551 / #552-#576. + - **Reçu de la 15-13** : AC 1 à 6, 10 a/b/d/e (partie MariaDB), 11 a/b/c/d (note MariaDB)/f (paragraphe + MariaDB)/g/h/j/k/l (contrôles MariaDB)/m, 12 a/a-bis/b, 13 a/b (Retiré)/c (phrases MariaDB), 14 ; T0 + (mesures Compose, `sqlx`, hôte du test 8, `make fr`), T1 (compose, gabarit), T2 (`config`, `main`), T3, + T4, T5, T6 (`DOCKER_START.md`, `init-demo.sh`, CHANGELOG), T7, T8, T9, T10 ; tests 1, 2, 4, 7, 8, 9, + 10, 13 ; mutations M1–M8, M12, M13, M16–M23, M27–M29 ; dix lignes du tableau des cas (deux scindées) ; + quatre angles morts. + - **Remédiation P3 appliquée ici** : **R3-2** (AC 12 b : grep qui exclut `CHANGELOG.md`, ligne de + CHANGELOG à l'inventaire) ; **F-P3-1** (AC 11 f et 13 c : « deux gestes » → décompte par fichier, cinq + phrases à `CHANGELOG.md:46` dont quatre ici ; contrôle négatif au PDF) ; **F-P3-2** (AC 13 d, + `CHANGELOG.md:52`, inventaire, grep (4) du T9) ; **F-P3-3** (décision de l'orchestrateur, C-15-13-17 : + AC 5 a-bis et d, test 7 étendu, **M42**, AC 11 b, 13 a, tableau des cas, Dev Notes rectifiées, angle + mort root gabarit tracé par issue) ; LOW **R3-3/F-P3-4** (#577 au T4, à l'inventaire, aux Issues), + **R3-4** (`docs/ci.md:9-10`), **R3-5** (`:210`), **R3-6** (sites du grep par la valeur triés : + `docs/kesh-specifications.txt`, `docs/testing.md:99`, `:161`, `DOCKER_START.md:41`, `:137`), + **R3-7** (`init-demo.sh:70-75`), **R3-8** (test 10 : `KESH_JWT_SECRET`, assertion « Erreur de + configuration »), **F-P3-6** (grep par la valeur sur tout le dépôt : `CHANGELOG.md`, `.env.example`, + commentaires des compose triés), **F-P3-8** (`DOCKER_START.md:12-13`), **F-P3-9** (helper sans + assertion, code ≠ 0 aux tests 8 et 10). + - **Constats du remédiateur** : M5 et le resserrement de `VALEURS_COMPOSEES` relevaient du test + `valeurs` (famille (V)), non de `transmission` ni du test 1 — **ligne 3a** neuve, AC 10 a précisé ; + grep `MARIADB_` (axe non exercé par les deux lentilles P3) rejoué : `retry.rs:57`, `:106` et + `reconciliation.rs:1129`, `:1136` ajoutés (homonymes) ; `crates/kesh-db/README.md:34` ajouté. + - **Recompte** (depuis cette fiche) : **11 AC** (numéros 1–6, 10–14 de la fiche unique), **11 tâches** + (T0–T10), **9 lignes de test** portant **8 fonctions Rust** (7 neuves, 1 existante modifiée) + 1 étape + CI ; **22 mutations** (M1–M8, M12, M13, M16–M23, M27–M29, M42) ; **10 lignes** au tableau des cas ; + **47 lignes** d'inventaire ; **4 angles morts** ; **2 modules de code**. +- 2026-10-09 — **Remédiation de la validation P4** (agent remédiateur, Opus 5.5, en autonomie). Passe P4 : + deux lentilles **Sonnet** en contexte frais (prompt `15-13a-validate-prompt-p4.md`) — R (regression + hunter) **0 CRITICAL / 0 HIGH / 1 MEDIUM / 7 LOW**, F (adversaire plein périmètre) **0 / 0 / 0 / 7 LOW** + (dont une vérification positive, F5) ; R4-1 = F1 (même défaut, vu MEDIUM par R, LOW par F) — soit + **1 MEDIUM distinct**. **Trend** (fiche unique puis 15-13a) : P1 (Opus 5.5 ×2) 8 MEDIUM distincts → P2 + (Sonnet ×2) 6 → P3 (Opus 5.5 ×2) 5 → découpage → P4 (Sonnet ×2) **1** ; 0 CRITICAL/HIGH aux quatre + passes. Le MEDIUM a deux moitiés : un trou d'inventaire d'**origine** (les quatre recettes de dev + rendues par `3306|kesh_dev`, jetons présents dès la fiche unique) et un jeton trop large **né de la + remédiation P3** (`deux gestes`, ajouté au grep au commit `a847f369` pour F-P3-1 — vérifié par + `git log -S`). Sévérité en baisse (5 → 1), aucun défaut de code : la boucle converge ; le résidu né de la + P3 est signalé à l'orchestrateur. + - **MEDIUM R4-1/F1** : inventaire complété (`docs/testing.md:171`, `:284`, `:302`, `CLAUDE.md:179`, dans + la ligne des recettes de dev, assumées) ; jeton `deux gestes` du T9 (1) restreint à + `(compose|décrit les)[^.]{0,20}deux gestes`, les six homonymes nommés ; grep (1) rejoué entier à + `c702b7d5` : aucun site hors inventaire. + - **LOW** : **F2** (`admin-manual.tex:227` est de la prose ; métrique « 21 » précisée : lignes du `.tex`, + autant au PDF aplati) ; **F3** (ligne d'inventaire `:959` ; angle mort « portée de la liste fermée », + `kesh_root` trié) ; **F4** (`decode_utf8_lossy()` tranché, témoin `%FF` au test 7, C-15-13-22) ; + **R4-4/F5** (forme non encodée établie à la source : cas positif ferme, repli retiré) ; **F6** (**M43** + au test 7, **M44** au test 10 avec l'assertion « pas de `mauvais-15-13` ») ; **F7** (recette de + changement : nouveau mot de passe ni dans l'historique ni dans `ps`, `stop kesh-api` d'abord, forme de + référence en Dev Notes, C-15-13-23 ; rubrique « Retiré » justifiée par Keep a Changelog 1.1.0, + vérifié en ligne) ; **R4-2** (`configuration_transmise.rs:626` nommé) ; **R4-3** (PDF versionnés : + régénérer après fusion, pas rebaser) ; **R4-5** (message « placeholder de gabarit », `generate_me`) ; + **R4-6** (en-tête des choix aligné sur le corps, citations ajoutées aux AC 2 c, 4 d, 11 f) ; **R4-7** + (fiche index : ventilation de l'inventaire retirée, totaux seuls). + - **Règle changée** : aucune règle métier. Précisions de contrat : décodage `lossy` (AC 5 a), propriété + « ni mot de passe ni URL » désormais gardée par mutation, exigences d'hygiène de la recette (AC 11 g). + - **Recompte** (depuis cette fiche, `grep`) : **11 AC**, **11 tâches**, **9 lignes de test** portant + **8 fonctions Rust** (7 neuves, 1 existante modifiée) + 1 étape CI ; **24 mutations** (M1–M8, M12, M13, + M16–M23, M27–M29, M42–M44 ; `sort -u` → 24) ; **10 lignes** au tableau des cas ; **48 lignes** + d'inventaire ; **5 angles morts** ; **2 modules de code** (inchangé). + +- **2026-10-09 — validation P5 ciblée (Haiku, prompt `15-13a-validate-prompt-p5-ciblee.md`)** : rapport + `target/gate-logs/15-13a-p5-ciblee.md`, **0 finding**, cinq axes déclarés exercés. Axe repris par + l'orchestrateur : le grep restreint du T9 (1) rejoué ne rend que `CHANGELOG.md:46` et + `admin-manual.tex:1793`, conformément à la fiche ; la recette `ALTER USER` passe bien par + `docker compose exec -T mariadb sh -c`. **Validation close.** Trend (15-13 puis 15-13a) : P1 8 MEDIUM → + P2 6 → P3 5 (découpage, C-15-13-16) → P4 1 → P5 ciblée 0. Modèles : Opus ×2, Sonnet ×2, Opus ×2, + Sonnet ×2, Haiku (ciblée). Signal D5 déclaré en P2 et P3 (recyclage), suivi d'un découpage en P3. + +- **2026-10-09 — T0 du développement** (agent de développement, Opus 5.5, worktree `kesh-15-13a`, base + `200f5e79` + planification `08816686`). Fiche relue contre le code : les numéros de `config.rs`, `main.rs`, + `configuration_transmise.rs`, `docker-compose.yml`, `.env.example` et `ci.yml` sont en place ; ceux du + manuel sont décalés de **+3** après la 15-7a2 à partir du script de sauvegarde (`exec -T db` `:1463`, + `:1514` ; `exec db` `:2219` ; « deux gestes » `:1796` ; référence des ports `:2365`) — relocalisés par le + texte. `sprint-status.yaml` : 15-13 (`split`), 15-13a (`in-progress`), 15-13b (`ready-for-dev`) ajoutées + (absentes de `main`). Mesures (détail au Dev Agent Record) : + - **Message `:?`** (Docker Compose **2.40.3**) : les deux formes c1 (non citée) et c2 (citée) se lisent + avec les variables posées (`config -q` → 0) ; sans elles, code 1 et message restitué **avec ses + accents**. **Forme retenue : c1** (valeurs non citées, comme les lignes voisines). + - **Écart à la fiche — ordre** : la variable nommée **n'est pas stable** : sur 12 lancements, + `MARIADB_ROOT_PASSWORD` 5, `MARIADB_PASSWORD` 3, `DATABASE_URL` de `kesh-api` 4 (forme c1). L'AC 11 j + (« corriger la première, la seconde apparaît au lancement suivant ») est écrit comme : une variable à + la fois, pas toujours la même — poser les deux (C-15-13a-1). + - **Écart à la fiche — sous-commandes** : sur un projet jetable en marche, sans les variables, + `config`, `pull`, `up -d` refusent (code 1) ; `ps`, `logs`, `exec -T mariadb true`, `stop` **passent** + (code 0). L'AC 11 f (« le refus frappe toute sous-commande », « le script de sauvegarde lancé par + `cron` échoue ») est donc écrit selon la mesure (C-15-13a-1). Aucune règle ni AC changé sur le fond : + le manuel dit vrai, ce que l'AC demandait. + - **Graphie de `docker compose config`** : un service avec `ports` donne la forme longue + (`ports:` / `- mode: ingress` / `host_ip:` / `target:` / `published: "…"`) ; le service `mariadb` + sans `ports` n'en montre aucune (`grep -c ports` sur son bloc → 0). + - **`sqlx`, mauvais mot de passe** (MariaDB 10.11.16 jetable) : `connect` rend immédiatement (0 ms) + `Database(MySqlDatabaseError { code: Some("28000"), number: 1045, … "(using password: YES)" })` ; + l'extraction `as_database_error()?.try_downcast_ref::()?.number()` rend + `Some(1045)`. Base absente pour un compte sans droit : **1044**. Pas de repli textuel nécessaire. + - **Hôte du test 8** : `kesh-15-13.invalid` échoue en **16 ms** (`Io`, résolution) ; `127.0.0.1:1` + attend **10 000 ms** (`PoolTimedOut`). Retenu : `kesh-15-13.invalid`. + - **`make admin brochure` d'avant** : référence `target/gate-logs/15-13a-t0/` (`.log` : 55 lignes + `Overfull` au manuel d'administration ; aplatis). Contrôles de l'AC 11 l sur l'aplati d'avant : + `openssl rand -hex 32 # → mot de passe MariaDB` 0, `MARIADB_ROOT_PASSWORD"'` 0, `--protocol=TCP` 0, + `CURRENT_USER()` 0, `compose lisible` 0 ; `deux gestes` 1 ; `exec (-T )?db` 3 ; brochure `MARIADB` 0. + - **`.env` local** : absent du worktree (aucune mesure faussée). +- 2026-10-09 — **Remédiation de la revue de code P1** (agent remédiateur, Opus 5.5, en autonomie). Passe P1 : + trois lentilles **Sonnet** en contexte frais (prompt `15-13a-review-prompt-p1.md`) — B (Blind Hunter) + **0 CRITICAL / 0 HIGH / 0 MEDIUM / 6 LOW**, E (Edge Case Hunter) **0 / 0 / 1 MEDIUM / 4 LOW**, A + (Acceptance Auditor) **0 / 0 / 1 MEDIUM / 5 LOW** ; E-1 et A-1 sont un même thème (un mot de passe + MariaDB neuf écrit dans `.env` sur une base déjà créée), soit **1 MEDIUM distinct** ; doublons LOW : + B-L6 = E-5 = A-4, E-3 = A-5. Tout est remédié sans code Rust de production (détail au Dev Agent Record), + sauf le nom de volume de B-L4, laissé à #575 (C-15-13a-4). Choix consignés : C-15-13a-3, C-15-13a-4. + La remédiation touche la recette du manuel (refondue et rejouée), `ci.yml` (configuration CI) et un test : + une passe suivante est requise par la règle (un MEDIUM en P1). +- 2026-10-09 — **Revue de code close, story `done`** (agent de clôture, Opus 5.5). Passe P2 **ciblée** + (une lentille, **Haiku 4.5**, contexte frais, prompt versionné `15-13a-review-prompt-p2-ciblee.md`, + rapport `target/gate-logs/15-13a-review-p2-ciblee.md`) sur le seul commit de remédiation `7faa16bb` : + **0 finding**, cinq axes déclarés exercés (distinction installation neuve / base déjà créée, recette + `ALTER USER`, `ci.yml`, `configuration_transmise.rs`, PDF du manuel), aucun déclaré non exercé ; l'axe + le plus cher (la distinction) repris par l'orchestrateur au grep. La remédiation P1 ne touchait aucune + ligne de code Rust de production. **Trend : P1 (Sonnet ×3) 1 MEDIUM distinct (E-1 = A-1) + LOW → P2 + ciblée (Haiku) 0.** Rebase sur `origin/main` sans objet (`200f5e79`, base inchangée). Gate complet + sur la tête `7e471fd4` : backend 2980/2980, Vitest 1095/1095, E2E 245 / 7 KF-029 + 2 KF-045 du matin, + étape CI rejouée verte (détail au Dev Agent Record, « Clôture »). diff --git a/_bmad-output/implementation-artifacts/15-13a-review-prompt-p1.md b/_bmad-output/implementation-artifacts/15-13a-review-prompt-p1.md new file mode 100644 index 000000000..dd22c21f5 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13a-review-prompt-p1.md @@ -0,0 +1,37 @@ +# Prompt — revue de code P1, Story 15-13a + +*Versionné le 2026-10-09. Trois lentilles (Sonnet), contexte frais chacune, en lecture seule (bmad-code-review : +Blind Hunter, Edge Case Hunter, Acceptance Auditor). Rotation D6 : passes complètes Sonnet ↔ Opus ; Haiku en passe ciblée.* + +Worktree `/home/gcorbaz/devel/kesh-15-13a`, branche `story/15-13a-mariadb-non-publiee`. **Le diff à revoir** : `git -C /home/gcorbaz/devel/kesh-15-13a diff 200f5e79..b9892834` (un seul diff aplati ; le code, +les tests, les manuels, la doc — les fiches `_bmad-output/` sont le contexte, pas l'objet). **Fiche** : +`_bmad-output/implementation-artifacts/15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md` (AC, tâches, Dev Agent Record, Change Log). Issues (par +`gh api repos/guycorbaz/kesh/issues/N`) : #551 (#577, #578 pour le contexte). Registre : `epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Choix C-15-13-1 à 29, C-15-13a-1, C-15-13a-2. Code et fichiers touchés : docker-compose.yml et docker-compose.prod.yml (port non publié, `${MARIADB_*_PASSWORD:?…}`), .env.example, config.rs/main.rs (avertissement kesh_dev et gabarit `<…>`, indice 1045, decode_utf8_lossy), tests (demarrage_mariadb.rs, configuration_transmise.rs, tests/common/binaire.rs), ci.yml (refus vérifié dans les deux sens) et docs/ci.md, manuel admin, brochure et PDF, DOCKER_START.md, init-demo.sh retiré, CHANGELOG. Axes : aucun mot de passe ni URL dans un journal (avertissements, indice 1045) ; l'étape CI peut-elle passer à vide ; la procédure de mise à jour du manuel et le tableau des cas (aucun cas qui pousse à écrire un mot de passe neuf dans .env) ; la recette ALTER USER (historique, ps) ; le texte sur les sous-commandes de Compose (mesuré sur 2.40 seulement : est-il écrit comme tel ?) ; manuel et brochure, PDF aplatis. + +## Lentilles + +- **B — Blind Hunter** : le diff seul, sans la fiche. Défauts de correction, régressions, erreurs de concurrence + (verrous, REPEATABLE READ, ordre d'acquisition), erreurs rendues au client, tests qui passeraient à vide (un test qui + ne mord pas sur la mutation qu'il prétend couvrir), code mort, duplication (DRY), doc-comments devenus faux. +- **E — Edge Case Hunter** : chaque branche et chaque borne du code modifié — entrées vides, nulles, multiples, en + doublon, d'une autre société, archivées ; chemins par clé d'API ; lots partiellement en échec ; locales ; et les + **chemins NON modifiés qui devraient l'être** (inventorier les sites non résolus de la même famille par `grep`). +- **A — Acceptance Auditor** : chaque AC de la fiche contre le code ET les tests (un AC sans test qui le prouve est + un finding) ; le Dev Agent Record ne déclare-t-il que ce qui a tourné (chiffres recomptés : `grep -c '#\[sqlx::test\]\|#\[test\]\|#\[tokio::test\]'` aux deux bornes) ; + le **manuel** (`docs/manual/fr/*.tex` **et PDF aplatis** : `pdftotext -nopgbrk f.pdf - | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/`, ligatures ff/fi/fl normalisées), `docs/api-external.md`, CHANGELOG, i18n 4 locales. + +## Ce que tu rends + +Rapport complet dans `target/gate-logs/15-13a-review-p1-.md` : findings numérotés, sévérité +(CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` pour toute affirmation de présence ou +d'absence ; code cité relu), correction proposée ; ⛔ **axes exercés ET non exercés**. Dernier message : le chemin, le +bilan par sévérité, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni +aucune commande qui compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, +`latexmk`, `git commit`/`add`/`checkout`/`stash`/`apply`, `sqlx`, `cargo`, `npm`, `npx`, +`gh issue create`/`comment`/`edit`, SQL d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, +`gh api` en lecture, `pdftotext` vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13a-review-prompt-p2-ciblee.md b/_bmad-output/implementation-artifacts/15-13a-review-prompt-p2-ciblee.md new file mode 100644 index 000000000..a3480b60c --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13a-review-prompt-p2-ciblee.md @@ -0,0 +1,38 @@ +# Prompt — revue de code P2 ciblée, Story 15-13a + +*Versionné le 2026-10-09. Passe ciblée (CLAUDE.md § « La passe ciblée ») : une seule lentille (Haiku), contexte +frais, en lecture seule, braquée sur le seul commit de la remédiation P1 — documentation, configuration CI et +un test ; aucune ligne Rust de production.* + +Worktree `/home/gcorbaz/devel/kesh-15-13a`, branche `story/15-13a-mariadb-non-publiee`. **Objet** : +`git show 7faa16bb` (diff unique ; ouvre les fichiers à `HEAD` autour des hunks). Rapports remédiés : +`target/gate-logs/15-13a-review-p1-{B,E,A}.md`. Choix C-15-13a-3, C-15-13a-4. + +## Lentille unique — chasseur de régressions de la remédiation + +1. **La distinction « installation neuve / base déjà créée »** : relis chaque site touché (`DOCKER_START.md`, + `docs/manual/fr/admin-manual.tex`, `CHANGELOG.md`). Un passage dit-il encore de générer une valeur neuve sans + condition ? Deux passages se contredisent-ils ? Rejoue le grep par la valeur + (`git grep -nE "openssl rand|générez|choisissez|MARIADB_ROOT_PASSWORD|MARIADB_PASSWORD" -- . ':(exclude)_bmad-output'`) + et vérifie le tri du rapport de remédiation. +2. **La recette `ALTER USER` refondue** (`admin-manual.tex`) : sous-shell `( set -e … )`, affichage avant + application, `read`, `printenv MARIADB_USER`, ligne témoin, `up -d` en second bloc. Est-elle exécutable + telle quelle (guillemets, `exec -T`, `\` de continuation) ? Un mot de passe peut-il se retrouver dans + l'historique ou dans `ps` ? Le cas « compte applicatif refusé » laisse-t-il bien root inchangé ? +3. **`ci.yml`** : `refus.txt` sous `$RUNNER_TEMP` ; le commentaire sur M27 exact ; l'étape peut-elle passer à vide ? +4. **`configuration_transmise.rs`** : la recherche par nom remplaçant `contenus[2]` peut-elle passer à vide ? +5. **PDF du manuel** : `pdftotext docs/manual/fr/admin-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers + `target/gate-logs/` — les phrases témoins du rapport sont-elles présentes, et `mot_de_passe_fort` absent ? + +## Ce que tu rends + +Findings numérotés (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` ; texte relu cité), +correction proposée. ⛔ **Liste des axes exercés ET non exercés** — un « 0 » sans elle ne compte pas. Rapport +complet dans `target/gate-logs/15-13a-review-p2-ciblee.md` ; dernier message : le chemin, le bilan, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni qui +compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `docker`, +`git commit`/`add`/`checkout`/`switch`/`stash`/`reset`, `sqlx`, `cargo`, `npm`, `npx`, `gh issue create`/`comment`/`edit`, +SQL d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`/`grep`, `pdftotext`. diff --git a/_bmad-output/implementation-artifacts/15-13a-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/15-13a-validate-prompt-p4.md new file mode 100644 index 000000000..dd5759c77 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13a-validate-prompt-p4.md @@ -0,0 +1,35 @@ +# Prompt — validation P4 de la spec, Story 15-13a + +*Versionné le 2026-10-09. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md`. La dernière remédiation est le commit `a847f369` (`git show a847f369`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #551 (contexte #577, #578). +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + les fiches). **La 15-13 vient d'être découpée (C-15-13-16)** : index `15-13-mariadb-et-sauvegarde.md` (historique des passes P1-P3 ; version complète au commit `8a9bcd27`), sous-fiches `15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md` et `15-13b-sauvegarde-avant-import-persistante.md` — relis les deux pour la frontière. Choix C-15-13-1 à 20. Vérifie que rien n'est perdu au découpage (contre `git show 8a9bcd27:_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md`), et que chaque sous-fiche est vraie si elle est mergée SEULE (CHANGELOG `[0.13.0]`, manuel, tableau des gestes). **Axes prioritaires** : AC 5 a-bis (avertissement du mot de passe applicatif gabarit via `is_template_placeholder`) et le test 7 avec des URL non encodées — le parsing de `DATABASE_URL` les accepte-t-il (lis le code de `config.rs` et la crate `url`) ? ; la réattribution de M5 au test `valeurs` ; le décompte des gestes (`admin-manual.tex:1793`, `CHANGELOG.md:46`, `:52`) ; les 47 lignes d'inventaire, à rejouer par les quatre greps du T9 ; la procédure de mise à jour et le tableau des cas ; le manuel et la brochure, PDF aplatis. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show a847f369`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13a-p4-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13a-validate-prompt-p5-ciblee.md b/_bmad-output/implementation-artifacts/15-13a-validate-prompt-p5-ciblee.md new file mode 100644 index 000000000..922d215ee --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13a-validate-prompt-p5-ciblee.md @@ -0,0 +1,42 @@ +# Prompt — validation P5 ciblée de la spec, Story 15-13a + +*Versionné le 2026-10-09. Passe ciblée (CLAUDE.md § « La passe ciblée ») : une seule lentille (Haiku), contexte +frais, en lecture seule, braquée sur la seule remédiation P4 de la fiche 15-13a. Aucune règle métier n'a changé.* + +Worktree `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Objet** : +`git show 3ebedca9 -- _bmad-output/implementation-artifacts/15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md` +(lis ce diff ; ouvre la fiche autour des hunks). Rapports remédiés : `target/gate-logs/15-13a-p4-{R,F}.md`. +Choix C-15-13-22, C-15-13-23. + +⚠️ **La story n'est pas développée** : tu relis une SPÉCIFICATION. Un test, un texte, une mutation ou une +constante que la fiche prescrit et qui n'existe pas encore dans le code n'est PAS un défaut. Un défaut est une +contradiction, une prescription inexécutable, un fait cité faux, ou un site oublié. + +## Lentille unique — chasseur de régressions de la remédiation + +1. **Grep restreint du T9 (1)** `(compose|décrit les)[^.]{0,20}deux gestes` : rejoue-le + (`git grep -nE "…" -- . ':(exclude)_bmad-output'`) et compare au texte de la fiche (seuls `CHANGELOG.md:46` + et `admin-manual.tex:1793` attendus). Rejoue aussi le grep (1) entier du T9 : un site manque-t-il à + l'inventaire (48 lignes) ? +2. **`decode_utf8_lossy()` (C-15-13-22)** et le témoin `%FF` du test 7 : cohérent avec l'AC 5 (b) (le démarrage + n'échoue pas) ? La crate `url`/`percent-encoding` fournit-elle bien ce décodage (lis + `~/.cargo/registry/src/*/percent-encoding-*/src/lib.rs`) ? +3. **M43, M44** : chacune fait-elle rougir le test qu'elle vise, telle qu'écrite ? +4. **Recette `ALTER USER` (C-15-13-23)** : le mot de passe n'apparaît-il vraiment ni dans l'historique du shell + ni dans `ps` (variable lue au clavier, puis entrée standard) ? La recette est-elle exécutable telle quelle + (`docker compose exec -T`, guillemets) ? +5. **Recomptes** (11 AC, 11 tâches, 9 lignes de test, 24 mutations, 10 cas, 48 lignes d'inventaire, 5 angles + morts, 2 modules). + +## Ce que tu rends + +Findings numérotés (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne`, **preuve** (sortie de `grep -nF` ; code relu cité), +correction proposée. ⛔ **Liste des axes exercés ET non exercés** — un « 0 » sans elle ne compte pas. Rapport +complet dans `target/gate-logs/15-13a-p5-ciblee.md` ; dernier message : le chemin, le bilan, une ligne par MEDIUM+. + +## Interdits + +⛔ N'écris aucun fichier hors `target/gate-logs/`. Aucune commande qui écrit dans le dépôt ou dans une base, ni qui +compile ou exécute des tests : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `docker`, +`git commit`/`add`/`checkout`/`stash`, `sqlx`, `cargo`, `npm`, `npx`, `gh issue create`/`comment`/`edit`, SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`/`grep`, lecture de `~/.cargo/registry/src/`. diff --git a/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md b/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md new file mode 100644 index 000000000..860079f30 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md @@ -0,0 +1,794 @@ +# Story 15.13b : la sauvegarde prise avant un import survit au redémarrage, et son échec est annoncé tel quel + +Status: ready-for-dev + + + +**Issues** : **ferme #552 et #576**. La PR porte `closes #552` **et** `closes #576` (mots-clés dans le titre +ou le corps de la PR : le dépôt merge en squash) ; les commits intermédiaires portent `refs #552` / +`refs #576`. **`refs #558`** seulement (dossiers d'hôte configurables : cette story n'en ajoute aucun, +C-15-13-3). + +#576 a été absorbée en validation P1 de la fiche unique (C-15-13-9) : avec le défaut `/data/backup`, +l'échec d'écriture de la sauvegarde devient le cas **nominal** de toute instance lancée hors Docker (montage +E2E, développement), et le message affiché promettait alors une sauvegarde qui n'existe pas. C'est pourquoi +#552 et #576 ne se séparent pas : livrer le nouveau défaut sans le nouveau message rendrait le message faux +dans le cas courant. + +**Dépendances** : après la **15-11a** et la **15-11b** (mergées dans `de285ea8`) — la variable +`KESH_ADMIN_BACKUP_DIR` est déjà transmise par les deux compose en `${KESH_ADMIN_BACKUP_DIR:-}`, lue par +`config::env_nonempty`, et le test `configuration_transmise.rs` garde la forme de cette transmission et les +trois montages de `kesh-api`. **Indépendante de la 15-13a en code** : aucune des deux n'appelle ce que +l'autre ajoute. Elles touchent les **mêmes fichiers**, à des endroits distincts : `docker-compose.yml`, +`.env.example`, `crates/kesh-api/src/config.rs`, `crates/kesh-api/tests/configuration_transmise.rs`, +`docs/manual/fr/admin-manual.tex` (§ *Passer à la 0.13.0*), `DOCKER_START.md`, `CHANGELOG.md` `[0.13.0]` — +et les **PDF versionnés** `admin-manual.pdf` et `marketing-brochure.pdf`, que les deux régénèrent. +La seconde mergée rebase et **recompte les gestes** du paragraphe « Pour qui garde son fichier compose » +(AC 11 f) ; un conflit sur un PDF ne se rebase pas : on fusionne les `.tex`, puis on **régénère** les PDF +(`make fr`) et on rejoue les contrôles aplatis des **deux** fiches (R4-3 de la validation P4 de la 15-13a). **Ordre suggéré : après la 15-13a** (#551 est P2, sécurité ; #552 et #576 P3) ; l'ordre inverse +est possible sans autre effet que le recompte. Release visée : **v0.13.0**, la même que la 15-11a — la +section du manuel « Passer à la 0.13.0 » et l'entrée `[0.13.0]` du CHANGELOG sont **complétées**, pas +doublées. + +**Si la 15-13b est mergée seule** (la 15-13a non encore mergée au tag) : le CHANGELOG `[0.13.0]` porte les +entrées **Corrigé** #552 et #576 et l'entrée **Sécurité** « dossiers montés ignorés » (AC 13 b) ; à +`CHANGELOG.md:46`, la fin de phrase « — la sauvegarde pré-import reste dans `/tmp` » et le décompte des +gestes sont réécrits (AC 13 c) ; les autres phrases de `:46` restent vraies (elles ne deviennent fausses +qu'avec la 15-13a). Le manuel porte les parties sauvegarde ; le paragraphe « Pour qui garde son fichier +compose » compte **trois** gestes pour chaque compose (les deux de la 15-11a, plus le montage `./backup`). + +## Story + +En tant qu'**exploitant d'une installation Kesh** (NAS Synology ou serveur Docker), +je veux que **la sauvegarde de sécurité prise avant un import d'installation soit écrite sur un dossier qui +survit au conteneur, et que l'échec de cette sauvegarde soit annoncé comme tel**, +afin de **pouvoir revenir en arrière après un import raté — y compris après le redémarrage qui suit souvent +l'échec —, et de ne jamais croire à une sauvegarde qui n'existe pas**. + +## Le défaut, établi au sol le 2026-10-09 + +Faits revérifiés au code (`grep -nF`, worktree `kesh-15-13`, `HEAD` = `de285ea8`) : + +### #552 — la sauvegarde pré-import vit dans `/tmp` du conteneur + +| Site | Contenu | +|---|---| +| `crates/kesh-api/src/config.rs:946-947` | `let admin_backup_dir = env_nonempty("KESH_ADMIN_BACKUP_DIR").unwrap_or_else(\|\| "/tmp".to_string());` | +| `crates/kesh-api/src/config.rs:250-256` | doc-comment du champ : « défaut `/tmp`. Créé s'il n'existe pas. La purge est à la charge de l'opérateur. » | +| `crates/kesh-api/src/config.rs:939-945` | commentaire de lecture (« Défaut `/tmp` ») | +| `crates/kesh-api/src/routes/admin.rs:259-261` | `build_keshbackup` puis `write_pre_import_backup(&state.config.admin_backup_dir, …)` **sous le verrou**, avant le restore | +| `crates/kesh-api/src/routes/admin.rs:471-495` | `create_dir_all(dir)` puis `tokio::fs::write(path, bytes)` — **mode par défaut** (umask du processus : `0644` sous l'umask `022` de l'image) | +| `docker-compose.yml:121`, `docker-compose.prod.yml:151` | `KESH_ADMIN_BACKUP_DIR: ${KESH_ADMIN_BACKUP_DIR:-}` — transmise (15-11a), **aucun montage** ne lui correspond | +| `.env.example:179-182` | « Défaut /tmp (absente ou vide) — PERDU au redémarrage du conteneur ; monter un volume persistant. » puis `#KESH_ADMIN_BACKUP_DIR=/data/backup` — **aucun compose ne monte `/data/backup`** | +| `admin-manual.tex:757`, `:1664`, `:1699` | « défaut `/tmp` » ×3 | +| `CHANGELOG.md:46` | « la sauvegarde pré-import reste dans `/tmp` » | + +Le conteneur `kesh-api` tourne en **root** (le `Dockerfile` n'a pas d'instruction `USER`), son système de +fichiers est éphémère : la sauvegarde disparaît à toute **recréation** du conteneur — `docker compose up -d` +après une modification de `.env` ou du compose, mise à jour de l'image —, c'est-à-dire précisément au +moment où l'on en a besoin après un import raté. Le fichier est un **secret** (condensés des mots de passe, +jetons de session : `admin-manual.tex:1654`) : le rendre persistant sur l'hôte impose de ne pas le +laisser lisible par tous (AC 9, C-15-13-4). + +### #576 — un échec d'écriture de la sauvegarde est annoncé comme une sauvegarde réussie + +| Site | Contenu | +|---|---| +| `crates/kesh-i18n/locales/*/messages.ftl` (`fr-CH:1478`, `de-CH:1407`, `en-CH:1407`, `it-CH:1407`) | `error-admin-full-import-failed` : « L'état précédent a été préservé (**un backup automatique a été créé** avant l'opération) » | +| `crates/kesh-api/src/errors.rs:1819-1828` | `AppError::AdminFullImportFailed` → 500, `ADMIN_FULL_IMPORT_FAILED`, cette clé — repli Rust « Échec de l'import de l'installation. L'état précédent a été préservé. » | +| `crates/kesh-api/src/routes/admin.rs:473`, `:487` | l'échec de création du dossier ou d'écriture du fichier rend **cette même** variante | +| `crates/kesh-api/src/routes/admin.rs:236`, `:240`, `:246` ; `admin_backup/import.rs:204` (via `check_schema_compat`, appelé `admin.rs:172`) | échecs **antérieurs** à la sauvegarde, même variante, même message | +| `crates/kesh-api/src/admin_backup/export.rs:103-105` (`map_db` → `AdminFullExportFailed`), appelé par `build_keshbackup` (`admin.rs:259`) | échec de lecture de la base pendant la sauvegarde : message « l'**export** n'a pas pu être généré », alors que l'utilisateur importait | + +Le frontend affiche le `message` du corps pour un 500 (`AdminRestorePanel.svelte:82-84`) : aucun code client +ne dépend de `ADMIN_FULL_IMPORT_FAILED` (`grep -rn ADMIN_FULL_IMPORT frontend/src` → vide). Les échecs +**postérieurs** à l'écriture (`admin.rs:283` à `:462`, douze sites) disent vrai : la sauvegarde existe et la +transaction est annulée. + +## Acceptance Criteria + +*(Numérotation de la fiche unique 15-13 conservée ; les AC 1 à 6 sont dans la 15-13a, et les sous-points +des AC 10 à 14 qui portent sur MariaDB aussi.)* + +### Volet B — la sauvegarde pré-import (#552) + +7. **Le défaut du code devient `/data/backup`.** (C-15-13-3.) + (a) `config.rs:946-947` : `KESH_ADMIN_BACKUP_DIR` absente, vide ou blanche → `/data/backup` (au lieu de + `/tmp`), par une constante nommée **publique** (`pub const DEFAULT_ADMIN_BACKUP_DIR`, `kesh_api::config`) + — pas de littéral dupliqué dans le code. **La valeur est liée par deux tests** (R4-1 de la validation P4, + C-15-13-24) : le test 6 b la compare au **littéral** `"/data/backup"` (le seul littéral côté test, + voulu : c'est la valeur dont tout #552 dépend), et le test 3 exige qu'elle soit la **cible** d'une + entrée de `MONTAGES` — donc d'un montage des deux compose (contrôle `transmission`). Une constante + changée en `/data/backups` ou en `/tmp` rougit les deux ; comparée à elle-même seulement, elle + dériverait au vert, et la sauvegarde retournerait sans signal dans le conteneur. + (b) Doc-comment du champ (`config.rs:250-256`) et commentaire de lecture (`:939-945`) mis à jour : + défaut `/data/backup`, monté par les compose distribués sur `./backup` ; **hors Docker**, poser la + variable sur un dossier inscriptible (sinon l'import est refusé avant toute suppression, la création + du dossier échouant, avec le message de l'AC 15). Les recettes du dépôt qui lancent un backend hors + Docker posent la variable (AC 16). + (c) Les constructeurs de test (`config.rs:508`, `:1508`) **gardent** `/tmp` : ce sont des + configurations de test, pas le défaut. + +8. **Les deux compose distribués montent `./backup` sur `/data/backup`.** + (a) `docker-compose.yml` et `docker-compose.prod.yml` : une entrée de `volumes:` du service `kesh-api`, + **fixe** dans les deux (`- ./backup:/data/backup`), avec un commentaire : sauvegarde de sécurité prise + avant chaque import d'installation ; **secret** ; dans le périmètre de sauvegarde du dossier du compose + (Hyper Backup) ; à purger par l'exploitant. Aucune variable `KESH_*_HOST_DIR` neuve (C-15-13-3, #558). + (b) `KESH_ADMIN_BACKUP_DIR: ${KESH_ADMIN_BACKUP_DIR:-}` **inchangé** dans les deux compose — le défaut + reste celui du code (C75), les couples de la liste `AJOUTS` du test restent. + (c) `.env.example:179-182` : commentaire réécrit (défaut `/data/backup`, monté sur `./backup` par les + compose fournis ; une autre valeur doit être un chemin **monté**, sans quoi la sauvegarde retourne dans + le système de fichiers éphémère du conteneur ; hors Docker, un dossier inscriptible) ; la ligne + d'exemple reste `#KESH_ADMIN_BACKUP_DIR=/data/backup`. Les deux phrases qui **énumèrent** les montages + fixes de `docker-compose.prod.yml` — `.env.example:208-210` (bloc des chemins d'hôte) et `:241-242` + (`KESH_LOG_HOST_DIR`), « ses montages sont fixes — `./inbox`, `./documents`, `./log` » — deviennent + incomplètes avec le quatrième : elles gagnent « et `./backup` (sauvegarde pré-import, sans variable + d'hôte) » (F-P4-3 de la validation P4). Le paragraphe du mode `cargo run` natif du même + gabarit (`.env.example:77-82`, « (d) Dev `cargo run` natif (hors Docker) sur Linux non-root », qui + pose `KESH_PORT`) gagne la même mention : hors Docker, `KESH_ADMIN_BACKUP_DIR` sur un dossier + inscriptible (F-P3-5). + (d) `docker-compose.dev.yml` n'est **pas** modifié (pile de développement non distribuée ; le conteneur + y crée `/data/backup` dans son système de fichiers — comportement de développement assumé). + (e) **Les dossiers d'hôte montés par défaut restent hors du dépôt et du contexte de build.** + `docker compose up` se lance depuis un clone (`DOCKER_START.md:27`, `:37`) : `./backup` y est créé au + premier import, et un `git add -A` y versionnerait une sauvegarde complète — un **secret**. Le symptôme + vaut pour chaque source de montage par défaut, pas pour `./backup` seul : `./inbox` et `./documents` + (justificatifs) ne sont pas ignorés non plus (`git check-ignore` muet, seul `log/` l'est, `.gitignore:34`). + Donc, **pour chaque entrée de `MONTAGES`** (sources `./log`, `./inbox`, `./documents`, `./backup`) : + `.gitignore` porte la ligne **ancrée** `//` et `.dockerignore` la ligne `/` — soit, + ajoutées, `/inbox/`, `/documents/`, `/backup/` au premier, `log/`, `inbox/`, `documents/`, `backup/` au + second (C-15-13-14, rectifié par C-15-13-20 ; C-15-13-19). **Le motif non ancré est refusé** (R3-1) : `backup/` dans `.gitignore` ignore aussi + `frontend/src/routes/(app)/admin/backup/`, **versionné** (`+page.svelte`, `+page.ts` — l'écran même de + la sauvegarde) — tout fichier neuf de cette route serait ignoré **en silence** (mesuré en validation P3 : + `git -c core.excludesFile= check-ignore -v --no-index "frontend/src/routes/(app)/admin/backup/nouveau.ts"` + → ignoré ; avec `/backup/` → non ignoré). **Une seule tolérance, nommée** : la ligne `log/` existante + (`.gitignore:34`) est gardée telle quelle — `git ls-files | grep -E '(^|/)log/'` → 0 (aucun dossier + `log` versionné nulle part dans le dépôt), si bien qu'elle ne masque rien aujourd'hui, et l'ancrer + ferait réapparaître dans `git status` les `log/` que des outils de dev créeraient sous les crates ; un + `log/` versionné ajouté demain serait masqué — risque écrit, accepté. `.dockerignore` n'est pas + concerné par l'ancrage : ses motifs sont relatifs à la racine du contexte (`backup/` ≡ `/backup`). + `git ls-files` ne rend aucun fichier sous ces quatre dossiers **à la racine** (rejoué en validation P4 + au `HEAD` `c702b7d5` : `git ls-files | grep -E '(^|/)(log|inbox|documents|backup)/'` ne rend que + `frontend/src/routes/(app)/admin/backup/+page.svelte` et `+page.ts`, hors racine) : aucune ligne ancrée + ne masque de contenu versionné. Garde : test 19. + +9. **La sauvegarde est créée en mode `0600`, et jamais par-dessus un fichier existant.** (C-15-13-4, + C-15-13-8.) + (a) `write_pre_import_backup` (`routes/admin.rs:471`) garde le choix du nom (inchangé) et délègue + l'écriture à une fonction factorisée **sur un chemin donné**, `write_backup_file(path: &Path, bytes: + &[u8]) -> Result<(), AppError>` (privée au module, appelée par les tests 11 et 12). **Écriture par + fichier temporaire, puis renommage** (F-P4-6 de la validation P4, décision de l'orchestrateur, + C-15-13-21) : elle crée `.partial` — **même dossier**, donc même système de fichiers — par + `tokio::fs::OpenOptions` avec `create_new(true)` et, sous `#[cfg(unix)]`, `mode(0o600)` ; écrit les + octets, `sync_all` ; puis **renomme** `.partial` en `` (`tokio::fs::rename`, atomique sur un + même système de fichiers). Un arrêt pendant l'écriture — client déconnecté (hyper abandonne le futur du + handler à un `.await`), OOM, `docker stop` — ne laisse donc qu'un `.partial`, **jamais un fichier nommé + comme une sauvegarde**. **Refus d'écraser** : `rename` remplace une cible existante sur Unix ; la + fonction vérifie donc, avant le renommage, que `` n'existe pas (`tokio::fs::try_exists`) et, sinon, + échoue (`.partial` supprimé au mieux). La fenêtre entre la vérification et le renommage n'est pas + fermée — le nom est unique par construction (horodatage, pid, compteur) et un second écrivain du même nom + serait déjà un défaut : angle mort écrit. Le journal `info` « backup pré-import écrit » suit le renommage + réussi. Si `write_all`, `sync_all` ou `rename` échoue, le `.partial` est supprimé au mieux + (`remove_file`, échec journalisé en `warn!` sans masquer l'erreur d'origine) ; si `remove_file` échoue à + son tour, le `warn!` **nomme le chemin** du `.partial` et dit qu'il n'est pas une sauvegarde valide. + Dans tous ces cas, le message de l'AC 15 c (« aucune sauvegarde n'a été créée ») reste **vrai** : aucun + fichier ne porte le nom d'une sauvegarde. Le doc-comment de `write_pre_import_backup` + (`routes/admin.rs:467-470`) dit le mode `0600`, l'écriture par `.partial` et renommage, le refus + d'écraser et la variante d'erreur. + (b) Le dossier, quand Kesh le crée, l'est en `0o700` (`tokio::fs::DirBuilder`, `recursive(true)`, + `mode(0o700)` sous `#[cfg(unix)]`) ; un dossier **existant** (le montage de l'hôte) n'est pas modifié. + (c) Une erreur rend `AppError::AdminPreImportBackupFailed` (AC 15) avec le détail actuel + (« création répertoire backup '…' : … », « écriture backup '…' : … ») : aucun import sans sauvegarde + réussie (DC5 inchangé). + (d) Le commentaire `routes/admin.rs:475-477` (« le backup pré-import est pris avant le verrou FOR + UPDATE ») est **faux** — il est pris sous le verrou (`:226-261`) — et corrigé au passage. + +### Volet C — le garde-fou et la documentation (parties sauvegarde) + +10. **`configuration_transmise.rs` garde ces décisions.** *(Les sous-points (a), (b) et la partie MariaDB + de (d) et (e) sont dans la 15-13a.)* + (c) **Montage de la sauvegarde** : `MONTAGES` gagne l'entrée `("/data/backup", "./backup", "./backup")` ; + le contrôle de `docker-compose.yml` exige l'**égalité** quand la source attendue ne commence pas par + `${` (aujourd'hui `starts_with` : `./backups` passerait) — les trois entrées existantes gardent le + préfixe `${KESH_…_HOST_DIR:-`. Le message de refus pour `docker-compose.prod.yml` reste celui de #558. + **La comparaison est extraite en fonction pure** (F-P4-2 de la validation P4, C-15-13-24) : + `source_conforme(compose: Compose, source: &str, attendue_y: &str, exacte_p: &str) -> bool` — pour `Y`, + `starts_with(attendue_y)` si `attendue_y` commence par `${`, égalité sinon ; pour `P`, égalité —, que la + boucle de `controle_transmission` (`configuration_transmise.rs:509-519`) appelle. Sans elle, la règle + d'égalité vit dans une boucle que l'auto-test (S) n'atteint pas, et rien ne la garde : les vrais + compose portent `./backup`, que `starts_with("./backup")` accepte aussi. + **Lien avec le défaut du code** (R4-1, C-15-13-24) : le test `transmission` exige en outre qu'une + entrée de `MONTAGES` ait pour cible `kesh_api::config::DEFAULT_ADMIN_BACKUP_DIR` (AC 7 a). + (d) **Auto-test (S)** : `s_sources_de_montage` exerce `source_conforme` — pour `Y` avec + `attendue_y = "./backup"` : `./backup` **accepté**, `./backups` (préfixe) **refusé**, + `${X:-./backup}` refusé ; pour `Y` avec `attendue_y = "${KESH_INBOX_HOST_DIR:-"` : + `${KESH_INBOX_HOST_DIR:-./inbox}` accepté ; pour `P` : `./backup` accepté, `./backups` refusé. Le cas + `./backups` en `Y` est celui que **M14** (retour à `starts_with` pour toutes les entrées) fait passer : + il rougit sous M14, ce que rien ne faisait avant la validation P4. + (e) Les textes qui comptent « trois » montages deviennent faux avec le quatrième : doc de `MONTAGES` + (« Les trois montages », `:196`), doc de `sources_montages` (« des trois montages », `:378` ; « l'une + des trois », `:379`) — `grep -n "trois" configuration_transmise.rs` rend exactement ces trois lignes au + `HEAD` `de285ea8`. + +11. **Le manuel d'administration dit vrai, et le PDF aussi** (parties sauvegarde). `docs/manual/fr/admin-manual.tex` : + (d) **`sec:env-vars`** : ligne `KESH_ADMIN_BACKUP_DIR` (`:757`) — défaut `/data/backup` (monté sur + `./backup`) ; **hors Docker : poser la variable sur un dossier inscriptible** (F-P3-5, comme les lignes + voisines `KESH_STATIC_DIR` et `KESH_LOCALES_DIR`, `:703-704`). Le paragraphe des montages d'hôte + (`:807-822`) dit que `./backup` est monté par les deux compose, **sans** variable d'hôte — dont la + phrase `:812` (« montages sont fixes — `./inbox`, `./documents`, `./log` »), qui énumère les montages + de `docker-compose.prod.yml` et gagne `./backup` (F-P4-3) ; le tableau `:819-821` (variables d'hôte) + n'a pas de ligne à gagner, `./backup` n'ayant pas de variable. **Les dix + tableaux de `sec:env-vars` restent sans `Overfull \hbox`** (acquis de la 15-11a). Le paragraphe du mode + `cargo run` natif (`:194`, « Mode `cargo run` natif (hors Docker) sur Linux non-root », qui pose + `KESH_PORT`) gagne une incise : hors Docker, `KESH_ADMIN_BACKUP_DIR` sur un dossier inscriptible, sans + quoi tout import est refusé (AC 15). + (e) **`sec:backup-ui-keshbackup`** (`:1664`, `:1667-1672`, `:1699`) : la sauvegarde de sécurité est + dans le dossier `backup/` du répertoire du compose (`/volume1/docker/kesh/backup` sur Synology), + nommée `kesh-pre-import--….keshbackup`, **créée en mode `0600`, propriétaire `root`** (le + conteneur tourne en root) — sur Synology, les ACL du dossier partagé peuvent s'y ajouter : limite non + mesurée, écrite comme telle ; c'est un **secret** ; purge à la charge de l'exploitant. **Restaurer + depuis elle** (C-15-13-8) : l'import se fait par le navigateur, et ni File Station, ni un partage SMB, + ni un `scp` sans `sudo` ne lisent un fichier `root` en `0600` ; le manuel donne donc le geste de + rapatriement, en SSH : + `sudo cp backup/kesh-pre-import-<…>.keshbackup /volume1//`, puis + `sudo chown /volume1//kesh-pre-import-<…>.keshbackup`, l'import par l'écran, + puis **la suppression de la copie** (secret) — geste **rejoué au T0** sur un conteneur jetable. La + liste « L'import refuse le fichier et préserve l'installation si » (titre `:1667`, liste `:1668-1672` — + **pas** `:1676-1690`, qui est la liste « Trois propriétés » des reprises de données) gagne l'entrée de + l'AC 15 : la sauvegarde de sécurité ne peut pas être écrite (dossier non inscriptible, disque plein) + — le message le dit, aucune sauvegarde n'est créée, rien n'est supprimé. Le défaut `/tmp` disparaît + de `:1664` et de `:1699`. **Fichier `.partial`** (AC 9 a, C-15-13-21) : le manuel dit qu'un + `kesh-pre-import-….keshbackup.partial` est une écriture interrompue, **pas** une sauvegarde, et qu'il + peut être supprimé. **Forme du rapatriement** (F-P4-8) : le bloc `sudo cp` / `sudo chown` est + introduit par « depuis le répertoire du compose » (le chemin `backup/…` est relatif) et posé dans un + `lstlisting` ordinaire (76 caractères), **pas** dans un `keshwarning` (70) : la ligne + `sudo chown /volume1//kesh-pre-import-<…>.keshbackup` en mesure 74 ; si la mise + en page l'exige ailleurs, elle est coupée par `\`. **Ouverture de la section** (`:1643`, « sans accès + SSH ni ligne de commande ») : vraie pour l'export et l'import par l'écran, mais la section reçoit + désormais un geste en SSH ; une phrase la précise — la sauvegarde de sécurité **pré-import** fait + exception, son rapatriement demande un accès SSH (voir plus bas) — (F-P4-7). Le tableau `:1712` + (« restauration self-service sans SSH ») et `website/roadmap.html:231` (« SSH-free migration and + restore ») parlent de l'export/import par l'écran et restent vrais : assumés, à l'inventaire. + (f) **`sec:maj-0-13`** (`:1757-1841`) — le paragraphe **sauvegarde** ; le paragraphe MariaDB est dans + la 15-13a. Re-télécharger le compose suffit pour le montage. **Pour qui garde son compose — le + paragraphe `:1793`** (F-P3-1, C-15-13-18) : le geste « ajouter le montage `./backup` » (sous `volumes:` de + `kesh-api`, **pas** sous `environment:` : la phrase d'ouverture « Sous `environment:` du service + `kesh-api` : » devient fausse et est réécrite) y est écrit, pour les deux compose, avec sa ligne de + contrôle sur la **cible du montage**, pas sur le chemin : `docker compose config | grep -c 'target: /data/backup'` + → 1 (graphie de la forme longue relevée au T0 ; si elle diffère, le motif suit la mesure, écrite au Dev + Agent Record). Le motif `/data/backup` seul ne discrimine pas : `docker compose config` imprime aussi + l'environnement, et un `.env` qui a décommenté `#KESH_ADMIN_BACKUP_DIR=/data/backup` (`.env.example:182` + — le public même de ce geste) fait sortir `KESH_ADMIN_BACKUP_DIR: /data/backup` **sans** montage + (R4-2/F-P4-1 de la validation P4). Le T0 vérifie le contrôle **rouge** (0) sur un compose sans le + montage avec cette variable posée. Le titre « Pour qui garde son fichier compose : **deux + gestes**. » perd son décompte global ; le paragraphe donne le décompte **par fichier**, recompté à + l'écriture sur l'état de la branche : avec la 15-13b seule, **trois** gestes pour chacun des deux + compose (les deux de la 15-11a, plus le montage) ; si la 15-13a est déjà mergée, **cinq** pour + `docker-compose.yml` et **trois** pour `docker-compose.prod.yml`. **Effets sans refus** (`:1773`) — + `KESH_ADMIN_BACKUP_DIR (un chemin non monté fait écrire dans le conteneur)` reste vrai ; y ajouter, + **conditionné au compose** (F-P3-1) : **avec le compose de la 0.13.0**, sans la ligne, la sauvegarde + va désormais dans `./backup` ; **avec un compose gardé** sans le montage, elle va dans `/data/backup` + **du conteneur** — éphémère, **comme avant** (le manuel n'écrit pas `/tmp` à cet endroit : la cellule + `:1773` commence par `KESH_ADMIN_BACKUP_DIR`, et un `/tmp` à moins de 90 caractères rougirait le contrôle + négatif `BACKUP_DIR.{0,90}/tmp` de l'AC 11 l — R4-7 ; l'ancien `/tmp` n'est nommé qu'au CHANGELOG, + AC 13 c). Le manuel fait partie du corpus du contrôle (F) de + `configuration_transmise.rs` (`:899-920`) : ne nommer **aucun** jeton `KESH_…` inexistant, pas même pour + dire qu'il n'existe pas (« aucune variable `KESH_BACKUP_…` » rougirait en fantôme). + (i) **Stratégie recommandée** (`:1417-1425`) : le dossier `./backup` est dans le périmètre du dossier + du compose ; il contient des secrets. + (k) Toute ligne de commande neuve dans un encadré `keshwarning` tient en **70 caractères** au plus, en + `keshnote`/`lstlisting` ordinaire en **76** (mesure de la 15-11a). + (l) **PDF régénéré** (`make fr` dans `docs/manual/`), contrôlé **aplati** + (`pdftotext admin-manual.pdf - | tr '\n' ' ' | tr -s ' '`, ligatures normalisées comme à la 15-11a). + Contrôles **discriminants** — chacun rouge sur le PDF d'avant (relevé au T0) : **positifs** — + `/data/backup` ; `sudo cp backup/` et `kesh-pre-import` (rapatriement — `sudo chown` figure déjà deux + fois au PDF d'avant et ne discrimine pas) ; tous vérifiés **absents** du PDF aplati à `468ce610` + (`target/gate-logs/15-13-p1-R-admin-plat.txt`) en remédiation P1 ; `keshbackup.partial` et + `target: /data/backup` (vérifiés absents, en remédiation P4, de + `target/gate-logs/15-13b-p4-F-admin-manual-plat.txt` : `grep -c partial` → 0) ; **négatifs** — + `grep -E 'BACKUP_DIR.{0,90}/tmp'` vide (la cellule du tableau, `:757`, que `défaut /tmp` ne voit pas) ; + `défaut /tmp` absent ; `deux gestes` absent (présent **une** fois au PDF d'avant, relevé en validation + P3 — discriminant). Au `.log`, aucun `Overfull \hbox` **nouveau** (comparé au `.log` d'avant, relevé au + T0). + (n) **Brochure** (`docs/manual/fr/marketing-brochure.tex:398`, « Export / import complet + d'installation (`.keshbackup`) pour migration ou restauration **sans accès SSH** ») **relue** au T5 + (F-P3-11) : la promesse reste vraie pour un `.keshbackup` exporté par le navigateur ; la sauvegarde de + sécurité **pré-import**, elle, ne se restaure qu'après un rapatriement en SSH (C-15-13-8). Laisser la + ligne telle quelle ou la nuancer — le choix et sa raison au Dev Agent Record ; si elle change, le PDF + de la brochure est régénéré par le même `make fr`. + +12. **`DOCKER_START.md`** (partie sauvegarde). + (a') Une ligne sur le dossier `./backup` : sauvegarde de sécurité prise avant un import, **secret**, + ignorée de git (AC 8 e), à purger. *(Le reste de l'AC 12 — prérequis, étape `.env`, accès à la base, + volume, « Base de données ne s'initialise pas », retrait d'`init-demo.sh` — est dans la 15-13a.)* + +13. **CHANGELOG `[0.13.0]`** (parties sauvegarde). + (b) **Corrigé** — entrée #552 : la sauvegarde prise avant un import va dans `./backup`, survit au + conteneur, créée en mode `0600` ; la restaurer demande de la rapatrier (manuel) ; hors Docker, + `KESH_ADMIN_BACKUP_DIR` à poser. Entrée #576 : un import dont la sauvegarde n'a pas pu être écrite + le dit, au lieu d'annoncer une sauvegarde qui n'existe pas. Rubrique **Sécurité** : les dossiers montés + par défaut (`backup`, `inbox`, `documents`, `log`) sont ignorés de git et du contexte de build (AC 8 e). + *(La rubrique **Retiré** `init-demo.sh` : 15-13a.)* + (c) **Propagation** : `CHANGELOG.md:46` porte **cinq** phrases que la 15-13 rend fausses (F-P3-1) ; + **deux** relèvent de la 15-13b, réécrites : « — la sauvegarde pré-import reste dans `/tmp` » (avec le + compose de la 0.13.0, elle va dans `./backup` ; avec un compose gardé sans le montage, dans + `/data/backup` du conteneur, éphémère) ; « le manuel d'administration décrit les **deux gestes** » (le + décompte par fichier de l'AC 11 f — trois et trois, ou cinq et trois si la 15-13a est mergée — ou un + renvoi sans nombre). Les trois autres (refus de Compose, défaut sans avertissement des mots de passe, + `.env` copié du gabarit) sont de la 15-13a. + **Et `CHANGELOG.md:44`** (entrée #550 de `[0.13.0]`, F-P4-3 de la validation P4) : « **Inchangé** : avec + `docker-compose.prod.yml`, les montages restent `./documents`, `./inbox`, `./log` » devient faux dans la + même version — la phrase gagne « ; la 0.13.0 y ajoute `./backup` (sauvegarde pré-import, #552) ». + +14. **Inventaire des sites non résolus tenu** (§ *Inventaire* des Dev Notes) : à la fin du développement, + chaque site de l'inventaire est soit résolu par un AC, soit écrit comme angle mort, et les greps de + contrôle (T9) ne trouvent **aucun** site hors de l'inventaire. + +### Volet D — le message d'un import refusé avant la sauvegarde (#576) et les recettes hors Docker + +15. **Un import qui échoue avant que la sauvegarde soit sur disque le dit, sans promettre de copie.** + (C-15-13-9.) + (a) Variante neuve `AppError::AdminPreImportBackupFailed(String)` (`errors.rs`) : HTTP 500, code + `ADMIN_PRE_IMPORT_BACKUP_FAILED`, clé `error-admin-pre-import-backup-failed`, détail journalisé et + jamais exposé (comme `AdminFullImportFailed`). Elle couvre **tout** échec antérieur à l'écriture + réussie du fichier : `routes/admin.rs:236`, `:240`, `:246` (transaction, verrou), `:473`, `:487` + (dossier, fichier — et `write_backup_file` de l'AC 9). Le doc-comment de `AdminFullImportFailed` + (`errors.rs:448-452`, « backup pré-import impossible ») est corrigé : elle ne couvre plus que les + échecs **postérieurs** à la sauvegarde (`admin.rs:283` à `:462`, douze sites, inchangés). + (b) Les deux sites qui naissent **hors** du module — `check_schema_compat` (`admin_backup/import.rs:204`, + appelé `admin.rs:172`) et `build_keshbackup` (`map_db` → `AdminFullExportFailed`, + `admin_backup/export.rs:103-105`, appelé `admin.rs:259`) — sont convertis **à l'appel**, dans + `routes/admin.rs`, par une fonction pure `avant_sauvegarde(AppError) -> AppError` qui change + `AdminFullImportFailed` et `AdminFullExportFailed` en `AdminPreImportBackupFailed` et rend toute autre + variante telle quelle (`ImportSchemaMismatch` reste un 400). `admin_backup` n'est pas modifié. + (c) Texte, **quatre locales** (`fr-CH`, `de-CH`, `en-CH`, `it-CH`) et **repli Rust** de `errors.rs` + (rendu quand aucun catalogue n'est chargé) : l'import a échoué **avant** la sauvegarde de sécurité ou + pendant son écriture ; **aucune sauvegarde n'a été créée** ; **rien n'a été supprimé** ; vérifier les + journaux du serveur, puis réessayer — **sans orienter vers une seule cause** (F-P4-5 de la validation P4, + C-15-13-25) : sur les cinq sites antérieurs à l'écriture, trois sont des pannes de **base** (transaction, + verrou, lecture du schéma ou des données), deux du **dossier** ; le texte nomme donc les deux pistes, à + égalité (« dossier de sauvegarde inscriptible, base de données accessible »), ou n'en nomme aucune. La + **négation** est la + forme attendue (« aucune sauvegarde n'a été créée », « Es wurde keine Sicherung erstellt », « No backup + was created », « Non è stato creato alcun backup ») : le test 16 n'interdit pas les mots « créé » / + « erstellt » / « created » / « creato », il interdit la **promesse** de l'ancien texte — la phrase + intérieure de sa parenthèse, **sans** les parenthèses, par locale (liste au test 16) : un texte neuf qui + reprendrait la promesse sans ses parenthèses rougit aussi (R3-9). Le texte de + `error-admin-full-import-failed` (après la sauvegarde) **ne change pas** : il dit vrai dans tous les + cas qu'il couvre désormais. Le repli Rust d'`AdminFullImportFailed` gagne « une sauvegarde de + sécurité a été écrite » pour que les deux replis se distinguent. **La négation est exigée, pas + seulement l'absence de la promesse** (F-P4-4) : le test 16 vérifie que le texte de chaque locale + **contient** sa négation (les quatre formulations ci-dessus, à la lettre) — un texte neuf « Échec de + l'import. » ne dirait plus qu'aucune copie n'existe, ce qui est l'objet de #576 ; le test 15 vérifie de + même que le repli Rust contient « aucune sauvegarde n'a été créée ». + (d) Frontend **non touché** : un 500 affiche le `message` du corps (`AdminRestorePanel.svelte:82-84`), + aucun code client ne lit `ADMIN_FULL_IMPORT_FAILED`. Le test de parité des catalogues + (`kesh-i18n/src/loader.rs:729-800`) reste vert sans entrée neuve dans `dette-parite-connue.txt`. + (e) Manuel : la liste des refus de `sec:backup-ui-keshbackup` (AC 11 e). + +16. **Les recettes du dépôt qui lancent un backend hors Docker posent `KESH_ADMIN_BACKUP_DIR`.** + Avec le défaut `/data/backup`, une instance lancée par `cargo run` sous un utilisateur ordinaire + refuse tout import (AC 15). Ce site est **invisible** du grep du T9 : c'est l'**absence** de la + variable qui le crée (§ *Inventorier les sites NON RÉSOLUS* du CLAUDE.md). **Portée exacte** : aucune + spec Playwright n'importe d'installation (`grep -rliE "keshbackup|full-import|admin/restore" + frontend/tests` → vide, rejoué en remédiation P2) ; la suite E2E ne + rencontre donc jamais le défaut. Ces recettes servent à qui lance, sur le backend qu'elles démarrent, + un import **à la main** par le navigateur (recette manuelle de Guy, développement) — c'est pour lui que + la variable est posée, pas pour la suite. Recettes recensées par **deux** greps (F-P3-5 : le premier, + par la formulation, ne voyait pas les modes `cargo run` documentés pour l'exploitant) : + `grep -rn "cargo run -p kesh-api" --exclude-dir={target,node_modules,.git,_bmad-output,_bmad,.claude,.svelte-kit} .` + puis `grep -rn "cargo run" …` (mêmes exclusions), et tri : + (a) **`CLAUDE.md`** § *E2E (Playwright)*, montage local (`:174-182`) : **deux** modifications, et + aucune autre (fichier d'instructions) — l'ajout de `KESH_ADMIN_BACKUP_DIR=target/kesh-backup` à la + ligne de commande de la recette, et, à `:174` (« Le montage qui marche, vérifié le 2026-08-04 »), + la mention que la commande a changé depuis : « vérifié le 2026-08-04 ; `KESH_ADMIN_BACKUP_DIR` ajoutée + le (15-13b), recette rejouée au T10 » — une date de vérification ne reste pas accolée à une + commande qui n'est plus celle qui a tourné. Le Dev Agent Record dit que c'est la recette **modifiée** + qui a tourné au T10. Cette recette a une copie **hors dépôt**, dans la mémoire de travail de + l'orchestrateur (F-P4-8 a) : la story ne la touche pas ; l'orchestrateur réalignera sa mémoire ; + (b) **`docs/testing.md`** : recette minimale (`:161-173`, qui crée déjà `/tmp/kesh-e2e/{inbox,documents}` + → ajouter `backup` au `mkdir -p` et `KESH_ADMIN_BACKUP_DIR=/tmp/kesh-e2e/backup`), recettes de + récupération de mot de passe (`:278-286`) et d'envoi d'e-mails (`:296-304`) — **toutes trois avec la même + valeur absolue** `KESH_ADMIN_BACKUP_DIR=/tmp/kesh-e2e/backup` (R4-8 de la validation P4) : la recette de + `:296-304` se lance depuis un autre répertoire (`KESH_STATIC_DIR=../frontend/build`), où un chemin + relatif pointerait ailleurs ; Kesh crée le dossier s'il manque (AC 9 b) ; + (b') **modes `cargo run` documentés pour l'exploitant** (F-P3-5) : `admin-manual.tex:194` (AC 11 d), + `.env.example:77-82` (AC 8 c), `README.md:90` (« en mode `cargo run` natif sur Linux non-root, lancer + le backend avec `KESH_PORT` … ») — chacun gagne la mention de `KESH_ADMIN_BACKUP_DIR` ; + (c) **assumés**, avec leur raison : `frontend/playwright.config.ts:18` (commentaire qui renvoie à + `docs/testing.md`) ; `frontend/DEBUGGING-KF007.md:17` (note de débogage historique, KF-007 close) ; + `docs/known-failures.md:119` (archivé, ne se modifie plus) ; `crates/kesh-db/README.md:57` et + `docs/testing.md:32` (citent `cargo run` sans le lancer) ; + `frontend/tests/e2e/password-recovery.spec.ts:16` (commentaire de recette **partielle** — la + `DATABASE_URL` y est élidée en `"mysql://..."` — dont la forme complète est `docs/testing.md:278-286`, + résolue en (b) ; le modifier toucherait le frontend, que l'AC 15 d laisse intact) ; + `frontend/tests/e2e/global-setup.ts:34` (message d'erreur « ex: cargo run -p kesh-api » : un + exemple, pas une recette) ; et ce que rend le second grep sans être une recette de backend : + `DOCKER_START.md:131`, `.env.example:32`, `:186`, `:249`, `docker-compose.dev.yml:2`, + `frontend/vite.config.ts:20`, `admin-manual.tex:668`, `:1315`, `docs/testing.md:61`, + `CHANGELOG.md:655` (mentions du mode, sans recette de lancement) ; `scripts/prepare-release.sh:109`, + `crates/kesh-db/src/post_restore.rs:1168`, `crates/kesh-db/tests/test_schema_guard.rs:548`, + `CLAUDE.md:798`, `docs/kesh-specifications.txt:1110` (autres binaires — exemple `kesh-db`, CLI + `kesh-seed` — ou prose). **`scripts/`** : aucun script ne lance de backend (`grep -rln "cargo run\|kesh-api" + scripts/` → `prepare-release.sh`, qui lance un exemple `kesh-db`, et `audit-tenant-scoping.py`, qui + n'en lance aucun). **CI** : `release.yml:91-101` lance l'image publiée (conteneur root, `/data/backup` + inscriptible) sans import ; `ci.yml` ne lance aucun backend. + +## Tasks / Subtasks + +- [ ] **T0 — Mesures avant d'écrire** (AC 11 e, 11 f, 11 l) + - [ ] Rapatriement de la sauvegarde (AC 11 e) rejoué sur un **conteneur jetable** : un conteneur root + écrit un fichier `0600` dans un dossier monté de l'hôte ; vérifier qu'il est illisible de + l'utilisateur, que `sudo cp` + `sudo chown` le rendent lisible, puis supprimer copie et dossier. + - [ ] Graphie de `docker compose config` pour un montage (forme longue `target: /data/backup`), pour la + ligne de contrôle de l'AC 11 f ; puis le contrôle **rouge** : sur une copie du compose **sans** le + montage, avec `KESH_ADMIN_BACKUP_DIR=/data/backup` posé dans l'environnement, + `docker compose config | grep -c 'target: /data/backup'` → **0** (alors que `grep -c '/data/backup'` + rend 1 : la ligne d'environnement) — R4-2/F-P4-1 de la validation P4. + - [ ] `make fr` sur l'état d'avant : garder le `.log` (référence des `Overfull`) et le PDF aplati + (référence des contrôles de l'AC 11 l, chacun **rouge** dessus). +- [ ] **T1 — Compose, gabarit, fichiers d'exclusion** (AC 8) + - [ ] `docker-compose.yml` et `docker-compose.prod.yml` : montage `./backup` + commentaire (rien d'autre + dans `docker-compose.prod.yml`). + - [ ] `.gitignore` (`/inbox/`, `/documents/`, `/backup/` — **ancrés**) et `.dockerignore` (`log/`, + `inbox/`, `documents/`, `backup/`) — AC 8 e ; `git ls-files` sous ces dossiers à la racine → vide ; + `git check-ignore -v --no-index "frontend/src/routes/(app)/admin/backup/+page.svelte"` → **non** + ignoré (sans `--no-index`, git ne rapporte jamais un fichier suivi : le contrôle ne pourrait pas + rougir — R4-3 de la validation P4 ; avec `backup/` non ancré, la même commande le rend ignoré). + - [ ] `.env.example` : commentaire `KESH_ADMIN_BACKUP_DIR` (`:179-182`), paragraphe (d) du mode + `cargo run` natif (`:77-82`), et les deux énumérations des montages fixes (`:208-210`, `:241-242`, + AC 8 c). +- [ ] **T2 — Code** (AC 7, 9, 15) + - [ ] `config.rs` : `pub const DEFAULT_ADMIN_BACKUP_DIR`, doc-comments. + - [ ] `routes/admin.rs` : `write_backup_file` (`.partial` en `create_new` + `0o600`, `sync_all`, refus si + le nom final existe, `rename` ; `.partial` supprimé au mieux en cas d'échec) ; dossier créé en `0o700` ; commentaire `:475-477` corrigé ; sites + antérieurs à la sauvegarde sur `AdminPreImportBackupFailed` ; `avant_sauvegarde` aux appels de + `check_schema_compat` et `build_keshbackup`. + - [ ] `errors.rs` : variante `AdminPreImportBackupFailed`, son bras de réponse et son repli ; doc et + repli d'`AdminFullImportFailed` (AC 15 a, c). + - [ ] `crates/kesh-i18n/locales/{fr-CH,de-CH,en-CH,it-CH}/messages.ftl` : clé + `error-admin-pre-import-backup-failed`, à côté de `error-admin-full-import-failed`. +- [ ] **T3 — Tests Rust** (AC 7, 8 e, 9, 10 c-e, 15) — voir § *Tests et mutations*. Dans + `configuration_transmise.rs` : quatrième entrée de `MONTAGES`, fonction pure `source_conforme` (égalité + exacte pour une source sans `${`), lien `DEFAULT_ADMIN_BACKUP_DIR` ↔ cible de `MONTAGES`, textes + « trois » mis à jour (AC 10 c, e) ; test 19 (AC 8 e). +- [ ] **T5 — Manuel et brochure** (AC 11) : sites (d) (ligne `KESH_ADMIN_BACKUP_DIR`, montages, `:194`), + (e), (f) (paragraphe sauvegarde, `:1773`, `:1793`), (i), puis relecture de la brochure (n) ; + `make fr` (trois PDF), contrôles (k)–(l). +- [ ] **T6 — `DOCKER_START.md`, recettes, CHANGELOG** (AC 12 a', 13, 16) — `CLAUDE.md` : la ligne de + commande de la recette et la mention de date de `:174` (AC 16 a), rien d'autre ; CHANGELOG `:44` + (montages de `docker-compose.prod.yml`) et `:46` (AC 13 c). +- [ ] **T8 — Mutations** : chacune appliquée, test rouge relevé, fichier restauré **et touché** + (`touch`, mémoire « mutation restaurée, binaire périmé ») ; tableau au Dev Agent Record. +- [ ] **T9 — Propagation et inventaire** (AC 14) — exclusions communes + `--exclude-dir={target,node_modules,.git,_bmad-output,_bmad,.claude,.svelte-kit}` : + (1) `grep -rnIE "BACKUP_DIR|pré-import|pre-import|admin-full-import-failed|AdminFullImportFailed|/tmp|(compose|décrit les)[^.]{0,20}deux gestes|backup/" …` + — chaque résultat est dans l'inventaire (résolu ou angle mort), sinon il y entre (le jeton `/tmp` + est large : trier les seuls sites qui parlent de la sauvegarde, les autres en bloc par fichier). Le + jeton des gestes est **restreint** comme dans la 15-13a (R4-4 de la validation P4 : `deux gestes` + seul rendait six homonymes sans rapport — `CLAUDE.md:760`, `routes/users.rs:334`, + `tests/users_e2e.rs:560`, `invoice_settlements.rs:395`, `user-manual.tex:1309`, + `invoice-unvalidate.spec.ts:67`) ; la forme restreinte ne rend que `CHANGELOG.md:46` et + `admin-manual.tex:1793` ; + (2) les deux greps de l'AC 16 (`cargo run -p kesh-api`, puis `cargo run`) ; + (3) `git ls-files | grep -E '(^|/)(log|inbox|documents|backup)/'` → seuls des fichiers hors de la + racine (aujourd'hui : `frontend/src/routes/(app)/admin/backup/`), qu'aucune ligne ancrée ne masque ; + (4) **par la valeur des montages** (F-P4-3 de la validation P4 : les greps (1) à (3) cherchent le + sujet, et ne voient pas une phrase qui **énumère** les montages sans nommer la sauvegarde) : + `grep -rnIE '\./(inbox|documents|log)\b' …` — tout site qui énumère les montages de + `docker-compose.prod.yml` (ou de `kesh-api`) en entier gagne `./backup`, les autres sont triés + (inventaire, ligne « grep (4) »). +- [ ] **T10 — Gates** : `scripts/test-fast.sh` complet (base du worktree remise à zéro avant — + `DROP`/`CREATE` de **ses** bases, jamais un redémarrage du conteneur), frontend non touché (gate + frontend tout de même, CLAUDE.md), **E2E complet au dernier commit de code** (D7), lancé avec la + recette corrigée de l'AC 16 ; la story ne touche aucune migration (exception `kesh-db` sans objet). + +*(T4 — CI — et T7 — recette MariaDB sur conteneur jetable — sont de la 15-13a.)* + +## Tests et mutations + +Périmètre : tests **neufs ou modifiés** par la 15-13b, de `de285ea8` au commit de développement. +Numérotation de la fiche unique conservée. + +| # | Test (fichier) | Établit | Mutation qui le rend rouge | +|---|---|---|---| +| 3 | `transmission` (existant, `configuration_transmise.rs:1733`, `MONTAGES` étendu, contrôle par `source_conforme` ; **et** : une entrée de `MONTAGES` a pour cible `kesh_api::config::DEFAULT_ADMIN_BACKUP_DIR`, R4-1) | AC 7 a, 8 a, 10 c | **M9** retirer `./backup:/data/backup` de `docker-compose.prod.yml` · **M10** `./backups:/data/backup` dans `docker-compose.yml` · **M11** `${KESH_BACKUP_HOST_DIR:-./backup}:/data/backup` dans `docker-compose.yml` (rouge aussi au (F) : fantôme) · **M15** (aussi au test 6 b) | +| 5 | `s_sources_de_montage` (existant, complété) — exerce la fonction pure `source_conforme` (AC 10 d) : en `Y` avec `"./backup"`, `./backup` accepté et `./backups` **refusé** ; en `Y` avec un préfixe `${…:-`, la source variable acceptée ; en `P`, égalité | AC 10 c-d | **M14** `source_conforme` revient à `starts_with` pour toutes les entrées (le cas `./backups` en `Y` passe) | +| 6 | `from_env_empty_or_blank_vars_take_code_default_silently` (existant, `config.rs:1648`, assertion par la constante — 6 a) + `from_env_absent_backup_dir_takes_data_backup` (nouveau — 6 b, assertion sur le **littéral** `"/data/backup"`, seul littéral voulu côté test : il fixe la valeur dont #552 dépend) | AC 7 | **M15** **valeur de la constante** `DEFAULT_ADMIN_BACKUP_DIR` changée en `"/tmp"` (geste naturel : 6 a, qui compare la constante à elle-même, reste vert ; 6 b et le test 3 rougissent) | +| 11 | `write_pre_import_backup_en_0600` (nouveau, `#[cfg(unix)]`, `mod tests` neuf de `routes/admin.rs`, `tempfile`) — `write_pre_import_backup` sur un sous-dossier absent d'un `TempDir` : dossier créé en `0700`, fichier en `0600`, contenu identique ; **assertions de montage** : un fichier témoin écrit par `std::fs::write` dans le même dossier n'est **pas** en `0600`, **et** un dossier témoin créé par `std::fs::create_dir` à côté n'est **pas** en `0700` (R4-9 de la validation P4 : sans ce second témoin, M25 passe sous umask `077`) — sinon l'umask rend le test non discriminant : échec explicite, dont le message **nomme la cause** (« umask de l'environnement trop restrictif (077 ?) : le test ne peut pas distinguer le mode posé par le code »), et non un défaut du code ; sous l'umask `022` du poste de dev et de la CI, sans effet. Après l'écriture, aucun `.partial` ne reste dans le dossier | AC 9 a-b | **M24** `.mode(0o600)` retiré · **M25** `DirBuilder` sans `mode` (dossier en `0755`) | +| 12 | `write_backup_file_n_ecrase_pas` (nouveau, même module) — `write_backup_file` sur un chemin final **déjà occupé** échoue en `AppError::AdminPreImportBackupFailed`, le fichier existant garde son contenu, et aucun `.partial` ne reste ; **+** `write_backup_file_passe_par_un_partiel` (nouveau, même module, C-15-13-21) — un `.partial` **préexistant** (écriture interrompue) fait échouer l'appel en `AdminPreImportBackupFailed`, et le nom final **n'existe pas** après l'échec : le nom de la sauvegarde n'apparaît qu'au terme d'une écriture complète passée par le `.partial` | AC 9 a, 9 c | **M26** vérification d'existence du nom final retirée (le `rename` écrase) · **M36** `write_backup_file` rend `AdminFullImportFailed` · **M46** écrire directement le nom final, sans `.partial` (le second test rougit : l'appel réussit, le nom final existe) | +| 14 | `avant_sauvegarde_convertit_les_echecs_anterieurs` (nouveau, unitaire, `routes/admin.rs`) — `AdminFullImportFailed` et `AdminFullExportFailed` → `AdminPreImportBackupFailed`, détail conservé ; `ImportSchemaMismatch` et `InvalidBackupStructure` rendus tels quels | AC 15 b | **M30** `avant_sauvegarde` rend l'erreur telle quelle · **M31** `avant_sauvegarde` convertit toute variante (un 400 devient un 500) | +| 15 | `pre_import_backup_failed_maps_to_500_without_promise` (nouveau, `errors.rs`, repli Rust — aucun catalogue n'est chargé dans les tests de la bibliothèque, `init_error_i18n` n'y est jamais appelé) — 500, `ADMIN_PRE_IMPORT_BACKUP_FAILED`, message sans le détail et sans promesse de sauvegarde, et qui **contient** « aucune sauvegarde n'a été créée » (F-P4-4) ; **témoin** : `AdminFullImportFailed` rend `ADMIN_FULL_IMPORT_FAILED` et un repli **différent** | AC 15 a, c | **M32** le bras de la variante neuve rend le code et la clé d'`AdminFullImportFailed` | +| 16 | `catalogues_distinguent_l_echec_de_sauvegarde` (nouveau, `errors.rs`, `I18nBundle::load` sur `concat!(env!("CARGO_MANIFEST_DIR"), "/../kesh-i18n/locales")` — chemin indépendant du répertoire courant (F-P3-10) —, **sans** toucher au catalogue global) — pour chacune des quatre locales : `error-admin-pre-import-backup-failed` ≠ `error-admin-full-import-failed` de la même locale ; hors `fr-CH`, ≠ le texte `fr-CH` (sinon `format` est retombé sur le français, `loader.rs:116-121`) ; ne contient pas la **promesse** de l'ancien texte, phrase intérieure de sa parenthèse **sans les parenthèses** (R3-9) : `un backup automatique a été créé avant l'opération`, `vor dem Vorgang wurde automatisch ein Backup erstellt`, `an automatic backup was created before the operation`, `prima dell'operazione è stato creato un backup automatico` — **pas** des sous-chaînes que la négation prescrite contient (`a été créé` est préfixe de « n'a été créée ») ; **assertion de montage** : chaque interdit figure dans `error-admin-full-import-failed` de **sa** locale (texte inchangé), faute de quoi l'interdit est une coquille et le test passe à vide. Vérifié en remédiation P3 : `grep -cF` de chaque interdit (sans parenthèses) → 1 dans le `messages.ftl` de sa locale (`fr-CH:1478`, `de-CH`/`en-CH`/`it-CH:1407`), et dans aucun autre ; `printf '%s' "" \| grep -cF -- ""` → 0 pour les vingt couples (quatre formulations de l'AC 15 c et « Non è stato creato un backup automatico », contre les quatre interdits) ; **et** (F-P4-4 de la validation P4) chaque texte neuf **contient** la négation de sa locale, à la lettre (`aucune sauvegarde n'a été créée`, `Es wurde keine Sicherung erstellt`, `No backup was created`, `Non è stato creato alcun backup`) | AC 15 c | **M33** texte `de-CH` remplacé par celui d'`error-admin-full-import-failed` · **M34** clé retirée d'`it-CH` (rouge aussi à `parity_between_locales`) · **M38** texte `fr-CH` neuf qui recopie la parenthèse de l'ancien · **M45** texte `fr-CH` neuf réduit à « Échec de l'import. » (sans négation ni promesse) | +| 17 | `full_import_refuses_when_backup_cannot_be_written` (nouveau, `admin_full_import_e2e.rs`) — `admin_backup_dir` = chemin d'un **fichier** existant (la création du dossier échoue même sous root : en CI comme en local) ; `spawn_app` factorisé en `spawn_app_with(pool, config)` (DRY) ; 500, `ADMIN_PRE_IMPORT_BACKUP_FAILED`, message = repli (ce binaire n'appelle pas `init_error_i18n`) ; installation intacte (société et administrateur d'origine, comme le test 18) | AC 15 a, 9 c | **M35** `admin.rs:473` (création du dossier) rend `AdminFullImportFailed` | +| 18 | `full_import_rolls_back_on_insert_failure` (existant, `admin_full_import_e2e.rs:559-605`, complété) — échec **après** la sauvegarde : code `ADMIN_FULL_IMPORT_FAILED` | AC 15 a | **M37** `admin.rs:291` (`restore :`) rend `AdminPreImportBackupFailed` | +| 19 | `montages_hors_du_depot` (nouveau, `configuration_transmise.rs`) — pour **chaque** entrée de `MONTAGES`, le dossier de sa source `docker-compose.prod.yml` (`./x` → `x`) a sa ligne dans `.gitignore` sous la forme **ancrée** `/x/` — **seule tolérance** : `log`, pour qui `log/` (ligne existante, `.gitignore:34`) est admise, la liste des tolérances étant une constante qui ne compte que `log` (assertion) — et dans `.dockerignore` (`x/`) ; la liste est **dérivée** de `MONTAGES`, pas recopiée (un cinquième montage ajouté demain est contrôlé sans retouche) ; **assertion de montage** : `MONTAGES` compte **au moins** quatre entrées, dont celle de cible `/data/backup` (R4-5 de la validation P4, C-15-13-26 : un compte exact obligerait à retoucher le test au cinquième montage, contre la phrase précédente), et chaque ligne est cherchée **entière** (après `trim`), pas en sous-chaîne (`log/` ne doit pas satisfaire `/backup/`, ni `backup/` satisfaire `/backup/`) | AC 8 e | **M39** retirer `/backup/` de `.gitignore` · **M40** retirer `backup/` de `.dockerignore` · **M41** `/backup/` remplacé par `backup/` (non ancré) dans `.gitignore` (R3-1) | + +**Décompte** (recompté sur le tableau) : 11 lignes, toutes côté Rust, qui portent **13 fonctions de test** +(9 neuves : lignes 6 b, 11, 12 ×2, 14, 15, 16, 17, 19 ; 4 existantes complétées : lignes 3, 5, 6 a, 18), dans +cinq fichiers existants (`configuration_transmise.rs`, `config.rs`, `routes/admin.rs`, `errors.rs`, +`admin_full_import_e2e.rs`) ; **22 mutations** (M9–M11, M14, M15, M24–M26, M30–M41, M45, M46), toutes +attendues rouges. M11 rougit **deux** familles ((T) et (F)), M15 et M34 deux tests chacune : attendu, le +relever. **M26** change de sens avec l'écriture par `.partial` : `create_new` porte désormais sur le +`.partial`, et c'est la vérification d'existence du nom final qui empêche l'écrasement. Non couverts par +un test, écrits comme angles morts : les sites `admin.rs:236`, `:240`, `:246` (panne de base avant la +sauvegarde, non injectable sans abstraction) et la suppression du `.partial` de l'AC 9 a après un échec de +`write_all`, `sync_all` ou `rename` (non injectable) — y compris l'échec de cette suppression (`.partial` +laissé, nommé par le `warn!`). + +Le test **(L)** (`garde_lecture_du_code`) et **(E)** (`env_example`) doivent rester verts **sans +modification** de leurs listes : la story n'ajoute aucune lecture d'environnement ni aucune variable. + +## Dev Notes + +### Ce que la story change, et ce qu'elle préserve + +- **Code** : trois modules de `kesh-api` — `config` (défaut, tests), `routes/admin` (écriture du fichier, + `avant_sauvegarde`), `errors` (variante de #576) — et les catalogues de `kesh-i18n` (une clé, quatre + locales). Aucune migration ; `kesh-db` non touché ; `admin_backup` non touché (AC 15 b) ; frontend non + touché (AC 15 d) ; les textes de l'écran de restauration disent « côté serveur », sans chemin — + `messages.ftl:1453`, `:1458` — et restent vrais. +- **Préservé** : DC5 (« jamais d'import sans backup réussi ») ; le texte d'`error-admin-full-import-failed` + (vrai pour tout ce qu'il couvre désormais) ; l'ordre verrou → sauvegarde → restore + (`routes/admin.rs:226-261`) ; le nom du fichier ; la réponse JSON (`backupCreated`) ; la transmission de + `KESH_ADMIN_BACKUP_DIR` en `${…:-}` (C75) ; les trois montages existants (C83, #558) ; + `docker-compose.dev.yml`. + +### La mise à jour d'une installation existante — les cas propres à la sauvegarde + +*(Les cas MariaDB sont dans la 15-13a.)* + +| État de l'installation avant mise à jour | Après la mise à jour | Ce que dit le manuel | +|---|---|---| +| compose **gardé** (non re-téléchargé), image 0.13.0 | sauvegarde dans `/data/backup` **non monté** (éphémère, comme avant) | le geste « ajouter le montage `./backup` » du paragraphe « Pour qui garde son fichier compose » (AC 11 f) ; l'« Effet sans refus » conditionné au compose le dit | +| compose neuf, **image ancienne** (oubli du `pull`) | sauvegarde toujours dans `/tmp` (défaut 0.12.x) | le manuel impose `docker compose pull` avant `up -d` (déjà écrit, 15-11a) | +| **restaurer après un import raté** (le cas même de #552) | la sauvegarde est dans `./backup`, `root` `0600` : illisible de File Station, de SMB et d'un `scp` sans `sudo` | rapatriement en SSH (`sudo cp` puis `sudo chown` vers un dossier partagé), import par l'écran, suppression de la copie (AC 11 e, C-15-13-8) | + +`docker-compose.prod.yml` (Synology, MariaDB externe) : seul change le montage `./backup`, créé par Docker +au premier `up -d` dans `/volume1/docker/kesh/` (propriétaire `root`, dans le périmètre Hyper Backup du +dossier du compose — `admin-manual.tex:1555`). + +### Pourquoi `/data/backup`, singulier, et un montage fixe (C-15-13-3) + +- Le gabarit propose **déjà** `#KESH_ADMIN_BACKUP_DIR=/data/backup` (`.env.example:182`) : un exploitant + qui l'a décommenté écrit déjà dans `/data/backup`. Monter **ce** chemin le rend persistant sans qu'il + ait rien à changer ; `/data/backups` (pluriel) aurait laissé ce cas dans le conteneur. +- Défaut **dans le code** (comme `/data/documents`, `/data/inbox`, `config.rs:951-957`) plutôt qu'un défaut + de déploiement dans le compose : C75 (le défaut vit dans le code, le compose transmet `${…:-}`) ; et un + compose **tiers** qui monte `/data` profite du changement. +- Montage **fixe** dans les deux compose : une variable d'hôte neuve relèverait de #558 (procédure de + déplacement, périmètre de sauvegarde) ; le test (F) rougirait d'ailleurs sur tout `KESH_*` inconnu. +- Hors Docker, `/data/backup` n'est pas inscriptible par un utilisateur ordinaire : l'import est alors + refusé **avant** toute suppression (`create_dir_all` échoue, `AdminPreImportBackupFailed` — dont le + message le dit désormais, AC 15), sans perte ; les recettes du dépôt et les modes `cargo run` documentés + posent la variable (AC 16). Le défaut `/tmp` d'avant ne protégeait pas mieux un serveur dont `/tmp` est + un tmpfs. + +### Pourquoi `0600` (C-15-13-4), et ce que cela coûte (C-15-13-8) + +Dans `/tmp` du conteneur, le fichier mourait avec lui ; sur l'hôte, il vit — dans un dossier que Docker +crée `root:root` en `0755`, et, sur `docker-compose.yml`, à côté de dossiers que le manuel invite à +partager en SMB. Le `.keshbackup` contient les condensés des mots de passe et les jetons de session +(`admin-manual.tex:1654`). `0600` (et `0700` pour un dossier que Kesh crée) est le minimum qu'impose +le changement de durée de vie. Refus d'écraser : le nom est unique (horodatage, pid, compteur) ; un +écrasement serait un défaut, autant qu'il échoue. **Persistant, un fichier interrompu en cours d'écriture +le devient aussi** (F-P4-6) : écrit sous son nom final, il serait indiscernable d'une sauvegarde valide +jusqu'à l'import, qui le refuserait en « corrompu » (SHA-256) au moment même où l'on en a besoin. D'où +l'écriture sous `.partial` puis le renommage (C-15-13-21) : seul un fichier complet et synchronisé porte le +nom d'une sauvegarde. + +Le prix est le rapatriement : un fichier `root` en `0600` ne se télécharge ni par File Station ni par SMB. +Il est **assumé** (C-15-13-8, décision de l'orchestrateur) plutôt qu'un mode plus large : la sauvegarde +contient toute la comptabilité et ses secrets, et le geste `sudo cp` + `sudo chown` tient en deux lignes +du manuel, rejouées au T0. Sur Synology, les ACL du dossier partagé `/volume1/docker` peuvent s'ajouter aux +droits POSIX : l'effet réel n'est pas mesuré depuis le poste, le manuel le dit (angle mort). + +### Inventaire fermé des sites — parties sauvegarde + +Greps de constitution : ceux du T9. Constitué à partir de l'inventaire de la fiche unique (57 lignes, +réparties entre les deux sous-fiches : § *Découpage* de la fiche index), puis complété des sites trouvés en +validation P3. **Résolu** = un AC le traite ; **assumé** = angle mort écrit. + +| Site | Sujet | Sort | +|---|---|---| +| `docker-compose.yml` `volumes:` de `kesh-api` ; `docker-compose.prod.yml` idem | montage sauvegarde | résolu (AC 8) | +| `docker-compose.yml:121`, `docker-compose.prod.yml:151` (`KESH_ADMIN_BACKUP_DIR: ${…:-}`) | transmission | inchangé, voulu (AC 8 b) | +| `.env.example:179-182` | gabarit, commentaire du défaut | résolu (AC 8 c) | +| `.env.example:77-82` | mode `cargo run` natif (hors Docker) | résolu (AC 8 c, F-P3-5) | +| `.env.example:208-210`, `:241-242` (« ses montages sont fixes — `./inbox`, `./documents`, `./log` ») | énumération des montages de `docker-compose.prod.yml` | résolu (AC 8 c, F-P4-3) | +| `CHANGELOG.md:44` (« **Inchangé** : … les montages restent `./documents`, `./inbox`, `./log` ») | énumération des montages, entrée #550 de `[0.13.0]` | résolu (AC 13 c, F-P4-3) | +| grep (4) du T9, reste : `docker-compose.yml:127-143`, `docker-compose.prod.yml:112-168` (commentaires et montages un par un), `docker-compose.dev.yml:34`, `.env.example:216`, `:237`, `:244`, `:263`, `:266`, `CHANGELOG.md:46` (« revient à `./documents` »), `:674`, `crates/kesh-api/tests/configuration_transmise.rs:200-205`, `:367`, `docs/kesh-specifications.txt:1739`, `admin-manual.tex:819-821`, `:837`, `:853`, `:884`, `:898`, `:1421`, `:1516`, `:1555`, `:1778` | un montage nommé seul, ou une variable d'hôte — aucune énumération de **tous** les montages | **assumé** — restent vrais avec le quatrième (`:1421`, `:1555` : périmètre de sauvegarde, AC 11 i ; `:200-205` : `MONTAGES`, résolu par l'AC 10 c ; `:819-821` : tableau des variables d'hôte, sans ligne pour `./backup`, AC 11 d) | +| `.github/workflows/release.yml:91-101` | lance l'image publiée (root, `/data/backup` inscriptible), sans import | **assumé** (AC 16) | +| `.gitignore` (seul `log/`, `:34`), `.dockerignore` (aucun) | dossiers montés par défaut (`./backup`, `./inbox`, `./documents`, `./log`) non ignorés dans un clone | résolu (AC 8 e, test 19) ; `log/` non ancré : tolérance nommée | +| `frontend/src/routes/(app)/admin/backup/+page.svelte`, `+page.ts` (versionnés) | homonyme du dossier `backup` | **assumé** — protégé par l'ancrage `/backup/` (AC 8 e, M41) | +| `crates/kesh-api/src/config.rs:250-256`, `:939-947`, `:1643-1661` | défaut `/tmp` | résolu (AC 7) | +| `crates/kesh-api/src/config.rs:508`, `:1508` | `/tmp` des constructeurs de test | **assumé**, voulu (AC 7 c) | +| `crates/kesh-api/src/config.rs:1586`, `:1601`, `:1748`, `:1769` | `KESH_ADMIN_BACKUP_DIR` comme variable témoin des tests (`reset_env`, `VIDE_EGALE_DEFAUT`, `env_nonempty`) | **assumé** — restent vrais, aucune valeur `/tmp` | +| `crates/kesh-api/src/routes/admin.rs:259-261`, `:471-495` | écriture | résolu (AC 9) | +| `crates/kesh-api/src/routes/admin.rs:475-477` | commentaire faux (« avant le verrou ») | résolu (AC 9 d) | +| `crates/kesh-api/src/routes/admin.rs:42`, `:129-130`, `:174`, `:219`, `:227`, `:481`, `:492` | doc et journal « backup pré-import » | **assumé** — restent vrais (`:481` est le nom du fichier, inchangé) | +| `crates/kesh-api/src/routes/admin.rs:467-470` | doc de `write_pre_import_backup` (« Échec d'écriture → 500 ») | résolu (AC 9 a : mode `0600`, refus d'écraser, variante d'erreur) | +| `crates/kesh-api/src/routes/admin.rs:236`, `:240`, `:246`, `:473`, `:487` ; `:172`, `:259` (appels) | échecs antérieurs à la sauvegarde | résolu (AC 15 a-b) | +| `crates/kesh-api/src/routes/admin.rs:283` à `:462` (douze sites `AdminFullImportFailed`) | échecs postérieurs | **inchangé, voulu** (AC 15 a) | +| `crates/kesh-api/src/errors.rs:448-452`, `:1819-1828` | variante et message de #576 | résolu (AC 15 a, c) | +| `crates/kesh-api/src/admin_backup/import.rs:204`, `admin_backup/export.rs:103-105` | variantes nées hors du module | résolu **à l'appel** (AC 15 b) ; fichiers non modifiés | +| `crates/kesh-api/src/admin_backup/export.rs:5` | doc « backup pré-import » | **assumé** — reste vrai | +| `crates/kesh-i18n/locales/*/messages.ftl` — `error-admin-full-import-failed` (`fr-CH:1478`, autres `:1407`) | message de #576 | résolu (AC 15 c) | +| `crates/kesh-api/src/routes/admin.rs:72-110` (`stream_via_tempfile`, `std::env::temp_dir()`) | fichier temporaire de l'**export** | **assumé** : temporaire par nature, supprimé après envoi | +| `crates/kesh-db/src/post_restore.rs:591`, `crates/kesh-db/tests/post_restore_transactionality.rs:8`, `crates/kesh-api/tests/admin_full_import_e2e.rs:704` | `AdminFullImportFailed` du rejeu des backfills, **postérieur** à la sauvegarde (R3-6, F-P3-7) | **inchangé, voulu** — échecs postérieurs, le message reste vrai ; `kesh-db` non touché | +| `crates/kesh-api/tests/configuration_transmise.rs:153-154` (`AJOUTS`), `:196`, `:199-207` (`MONTAGES`), `:378-379` | transmission ; montages ; textes « trois » | `:153-154` inchangé, voulu (AC 8 b) ; `:196`, `:199-207`, `:378-379` résolus (AC 10 c, e) | +| `CLAUDE.md:178-182`, `docs/testing.md:161-173`, `:278-286`, `:296-304` | recettes `cargo run -p kesh-api` sans `KESH_ADMIN_BACKUP_DIR` | résolu (AC 16 a-b) | +| `admin-manual.tex:194`, `README.md:90` | modes `cargo run` natif documentés pour l'exploitant | résolu (AC 16 b', 11 d) | +| `frontend/playwright.config.ts:18`, `frontend/DEBUGGING-KF007.md:17`, `docs/known-failures.md:119`, `crates/kesh-db/README.md:57`, `docs/testing.md:32` | autres mentions de `cargo run -p kesh-api` | **assumé** (AC 16 c) | +| `frontend/tests/e2e/password-recovery.spec.ts:16`, `frontend/tests/e2e/global-setup.ts:34` | `cargo run -p kesh-api` | **assumé** (AC 16 c) | +| `DOCKER_START.md:131`, `.env.example:32`, `:186`, `:249`, `docker-compose.dev.yml:2`, `frontend/vite.config.ts:20`, `admin-manual.tex:668`, `:1315`, `docs/testing.md:61`, `CHANGELOG.md:655` | mentions du mode `cargo run`, sans recette de lancement (second grep de l'AC 16) | **assumé** (AC 16 c) | +| `scripts/prepare-release.sh:109`, `crates/kesh-db/src/post_restore.rs:1168`, `crates/kesh-db/tests/test_schema_guard.rs:548`, `CLAUDE.md:798`, `docs/kesh-specifications.txt:1110` | `cargo run` d'autres binaires, ou prose | **assumé** (AC 16 c) | +| `DOCKER_START.md` — section « dossiers » (ligne neuve) | `./backup` | résolu (AC 12 a') | +| `admin-manual.tex:757`, `:807-822` (dont `:812`, énumération des montages fixes) | `sec:env-vars` (ligne `KESH_ADMIN_BACKUP_DIR`, montages) | résolu (AC 11 d, F-P4-3) | +| `admin-manual.tex:1643` (« sans accès SSH ni ligne de commande », ouverture de `sec:backup-ui-keshbackup`) | contradiction avec le rapatriement en SSH de la même section | résolu (AC 11 e, F-P4-7 : phrase d'exception) | +| `admin-manual.tex:1712` (« restauration self-service sans SSH »), `website/roadmap.html:231` (« SSH-free migration and restore ») | promesse de l'export/import par l'écran | **assumé** — vraie pour un `.keshbackup` exporté ; la sauvegarde pré-import en fait exception, dite à `:1643` (F-P4-7) | +| `admin-manual.tex:1417-1425`, `:1664`, `:1667-1672`, `:1699` | sauvegarde | résolu (AC 11 e, i) | +| `admin-manual.tex:1452-1477`, `:2312-2317` (`BACKUP_DIR` du script de sauvegarde) | homonyme : variable de shell, pas de Kesh | **assumé** — hors sujet (le `cd` et `exec db` du même script : 15-13a, AC 11 h) | +| `admin-manual.tex:1757-1841` — paragraphe sauvegarde, `:1773` (« Effets sans refus »), `:1793` (« deux gestes », « Sous `environment:` ») | mise à jour 0.13.0 — parties sauvegarde | résolu (AC 11 f) | +| `docs/manual/fr/marketing-brochure.tex:398` | « restauration sans accès SSH » | relu (AC 11 n) — vrai pour un `.keshbackup` exporté ; décision au Dev Agent Record | +| `CHANGELOG.md:46` — « — la sauvegarde pré-import reste dans `/tmp` » et « deux gestes » | rendues fausses | résolu (AC 13 c) | + +**Décompte** : 41 lignes (recompté sur le tableau — `grep -c '^| '` rend 42, en-tête compris ; le +séparateur commence par `|---` et n'est pas compté) : 36 à la validation P4, plus cinq (F-P4-3 : `.env.example`, +`CHANGELOG.md:44`, reste du grep (4) ; F-P4-7 : `:1643`, `:1712`/`roadmap.html`) ; `:812` enrichit une ligne +existante. + +### Angles morts assumés (écrits, non traités) + +- **ACL Synology** sur `/volume1/docker/kesh/backup` : peuvent élargir ou restreindre l'accès au fichier + `0600` ; non mesurées depuis le poste (AC 11 e). +- **Échecs antérieurs à la sauvegarde sans test** : `routes/admin.rs:236`, `:240`, `:246` (panne de base + avant le verrou) et la suppression du `.partial` de l'AC 9 a — non injectables sans abstraction ; + relus en revue, écrits ici. +- **`.partial` non supprimé** : si l'écriture échoue après la création **et** que `remove_file` échoue à + son tour — ou si le processus est arrêté pendant l'écriture —, un `….keshbackup.partial` reste dans le + dossier de sauvegarde ; il ne porte pas le nom d'une sauvegarde, le message « aucune sauvegarde n'a été + créée » reste vrai, le `warn!` nomme le chemin, et le manuel dit qu'un `.partial` peut être supprimé + (AC 9 a, 11 e, C-15-13-21). Suppression non injectable, non testée. +- **Fenêtre entre la vérification du nom final et le renommage** : non fermée (`rename` remplace une cible + existante sur Unix ; `renameat2(RENAME_NOREPLACE)` n'est pas dans `std`, et un `hard_link` sans écrasement + dépend du système de fichiers du montage) ; sans portée pratique — le nom est unique par construction, et + un second écrivain du même nom serait déjà un défaut (C-15-13-21). +- **Durabilité du renommage** : le dossier n'est pas synchronisé après `rename` ; une coupure de courant + juste après pourrait perdre l'entrée du dossier — le fichier était complet et synchronisé, seul le nom + serait en cause. Non traité. +- **SELinux** : le montage `./backup` n'a pas de suffixe `:z` (les trois montages existants non plus) ; + sur un hôte SELinux en mode `enforcing`, l'écriture pourrait être refusée — l'import le dirait alors + (AC 15). Non mesuré. +- **Purge des sauvegardes** : à la charge de l'exploitant (inchangé) ; elles sont désormais persistantes, + donc elles s'accumulent — une par import, de la taille de la base. Le manuel le dit ; pas de rotation. +- **Emplacement non communiqué à l'écran** : la réponse de l'import ne porte pas le nom du fichier + (`backupCreated` seulement) ; écarté (C-15-13-6). +- **Contrôle au démarrage du dossier de sauvegarde** (un `warn!` si `/data/backup` n'est pas + inscriptible) : écarté (C-15-13-9) — le message de l'AC 15 dit la cause au moment où elle compte, et les + recettes hors Docker posent la variable (AC 16). +- **Montage d'hôte `./backup` créé `root:root` `0755`** : l'exploitant qui le veut fermé fait + `chmod 700 backup` (manuel) ; les fichiers sont `0600` de toute façon. + +### Règle de découpage + +**Deux crates, quatre modules de code** — `kesh-api::config` (le défaut seulement), `kesh-api::routes::admin`, +`kesh-api::errors` et les catalogues de `kesh-i18n` (`locales/*/messages.ftl`, un seul module métier +quelle que soit la locale) —, plus des tests (`configuration_transmise.rs` étendu, +`admin_full_import_e2e.rs`) et des fichiers de configuration et de documentation (`docker-compose.yml`, +`docker-compose.prod.yml`, `.env.example`, `.gitignore`, `.dockerignore`, `CLAUDE.md` — la recette et sa +date —, `docs/testing.md`, `README.md`, manuel (+ PDF, brochure relue), `DOCKER_START.md`, `CHANGELOG.md`). +Convention de décompte (celle de la fiche unique) : le seuil compte les **modules de code** au sens du +`CLAUDE.md`. Seuil (« plus de 5 ») non atteint. `admin_backup` **n'est pas** touché : ses deux variantes sont +converties à l'appel (AC 15 b), précisément pour ne pas ouvrir un module de plus. + +### Références + +- Fiche index `15-13-mariadb-et-sauvegarde.md` (découpage, recompte, Change Log des validations P1 à P3) ; + fiche unique complète au commit **8a9bcd27**. +- Issues #552, #576 (absorbée en validation P1), #558 (cahier des charges hérité, trois commentaires). +- Rapports de validation (non versionnés) : `target/gate-logs/15-13-p{1,2,3}-{R,F}.md` ; prompts + `15-13-validate-prompt-p{1,2,3}.md`. +- `_bmad-output/implementation-artifacts/15-11a-compose-transmet-la-configuration.md` — § *Angles morts* + (`#552`), AC 12 (conventions du manuel : 76/70 caractères, `make fr`, contrôle aplati, `Overfull` des + dix tableaux). +- `_bmad-output/implementation-artifacts/15-11b-lecture-unique-des-variables.md` — `env_nonempty`, test + (L). +- `crates/kesh-api/tests/configuration_transmise.rs` — `MONTAGES` (doc `:196-198`, `const` `:199-207`), `controle_transmission` + (`:443-533`), test `transmission` (`:1733`), `s_sources_de_montage` (`:1969`). +- CLAUDE.md : § *Test Locally First* (E2E au dernier commit de code, D7), § *Propagation post-patch*, § + *Recompter ses propres comptes rendus*, § *Le prompt d'une passe doit NOMMER le manuel*, § *Inventorier + les sites NON RÉSOLUS*, § *Issue Tracking Rule* (mots-clés sur la PR). + +## Dev Agent Record + +### Agent Model Used + +### Debug Log References + +### Completion Notes List + +### File List + +## Change Log + +- 2026-10-09 — **Création par découpage de la 15-13, et remédiation de la validation P3** (agent remédiateur, + Opus 5.5, en autonomie). Historique de la fiche unique (spécification, validations P1 et P2) : Change Log + de la fiche index. **Trend** de la fiche unique : P1 (Opus 5.5 ×2) 8 MEDIUM distincts → P2 (Sonnet ×2) 6 + → P3 (Opus 5.5 ×2 : R 2 MEDIUM / 8 LOW, F 3 MEDIUM / 8 LOW, aucun doublon) **5 MEDIUM distincts** ; 0 + CRITICAL/HIGH aux trois passes. **Signal D5 de recyclage** levé deux passes de suite (P2 : deux MEDIUM nés + de la P1 ; P3 : R3-1, R3-2 et F-P3-2 nés de la P2) → **découpage** décidé par l'orchestrateur + (C-15-13-16) selon la couture #551 / #552-#576. + - **Reçu de la 15-13** : AC 7, 8, 9, 10 c/d (montage)/e (« trois »), 11 d (ligne `KESH_ADMIN_BACKUP_DIR`, + montages)/e/f (paragraphe sauvegarde)/i/k/l (contrôles sauvegarde), 12 a (ligne `./backup`, devenue + 12 a'), 13 b (#552, #576, Sécurité)/c (phrase `/tmp`), 14, 15, 16 ; T0 (rapatriement, `make fr`), T1 + (montages, exclusions, gabarit), T2 (`config`, `routes/admin`, `errors`, catalogues), T3, T5, T6 + (recettes, CHANGELOG), T8, T9, T10 ; tests 3, 5, 6, 11, 12, 14 à 19 ; mutations M9–M11, M14, M15, + M24–M26, M30–M40 ; trois lignes du tableau des cas (deux scindées) ; huit angles morts. + - **Remédiation P3 appliquée ici** : **R3-1** (AC 8 e : motifs **ancrés** `/inbox/`, `/documents/`, + `/backup/`, tolérance nommée pour `log/` et sa justification ; test 19 qui l'exige ; **M41** ; homonyme + `frontend/src/routes/(app)/admin/backup/` à l'inventaire et à C-15-13-14) ; **F-P3-1** (AC 11 f et + 13 c : « deux gestes » → décompte par fichier ; « sans la ligne → `./backup` » conditionné au compose + 0.13.0 ; contrôle négatif au PDF) ; LOW **R3-6/F-P3-7** (`post_restore.rs:591`, + `post_restore_transactionality.rs:8`, `admin_full_import_e2e.rs:704` à l'inventaire), **R3-9** (test + 16 : interdits sans les parenthèses, revérifiés par `grep -cF` sur les quatre catalogues et les vingt + couples de négations), **F-P3-5** (modes hors Docker : AC 11 d, `admin-manual.tex:194`, + `.env.example:77-82`, `README.md:90` ; AC 16 étendu au grep `cargo run` sans `-p`, sites triés), + **F-P3-10** (`CARGO_MANIFEST_DIR`), **F-P3-11** (brochure `:398`, AC 11 n). + - **Recompte** (depuis cette fiche) : **10 AC** (numéros 7–16 de la fiche unique), **9 tâches** (T0–T3, + T5, T6, T8–T10), **11 lignes de test** portant **12 fonctions Rust** (8 neuves, 4 existantes + complétées) ; **20 mutations** (M9–M11, M14, M15, M24–M26, M30–M41) ; **3 lignes** au tableau des cas ; **36 + lignes** d'inventaire ; **8 angles morts** ; **4 modules de code**. +- 2026-10-09 — **Remédiation de la validation P4** (agent remédiateur, Opus 5.5, en autonomie). Passe P4 : + deux lentilles **Sonnet** en contexte frais (prompt `15-13b-validate-prompt-p4.md`) — R (regression + hunter) **0 CRITICAL / 0 HIGH / 2 MEDIUM / 8 LOW**, F (adversaire plein périmètre) **0 / 0 / 3 MEDIUM / + 5 LOW** ; R4-2 = F-P4-1 — soit **4 MEDIUM distincts**. **Trend** (fiche unique puis 15-13b) : P1 (Opus 5.5 + ×2) 8 MEDIUM distincts → P2 (Sonnet ×2) 6 → P3 (Opus 5.5 ×2) 5 → découpage → P4 (Sonnet ×2) **4** ; + 0 CRITICAL/HIGH aux quatre passes. **Signal D5** (sévérité égale, MEDIUM → MEDIUM) **déclaré au Project + Lead via l'orchestrateur** : les quatre MEDIUM sont distincts de ceux de la P3 ; **trois** sont d'origine + — R4-1 (constante liée à rien, `DEFAULT_ADMIN_BACKUP_DIR` depuis la spécification `d023c019`), F-P4-2 (M14 + sans test, depuis `d023c019`), F-P4-3 (trou d'inventaire) — mais **un naît de la remédiation P3** : + la ligne de contrôle `docker compose config | grep -c '/data/backup'` de R4-2/F-P4-1 apparaît au commit + `a847f369` (vérifié par `git log -S`), écrite pour F-P3-1. C'est la forme qui découpe selon + l'amendement D5 ; elle est ici **isolée** (un défaut sur quatre, dans un texte du manuel, non dans le + code) et la fiche est déjà issue d'un découpage, à quatre modules : pas de nouveau découpage proposé par + le remédiateur — l'arbitrage revient à l'orchestrateur. + - **MEDIUM** : **R4-1** (constante `pub`, liée au littéral par le test 6 b et à la cible de `MONTAGES` + par le test 3 ; M15 réécrite « valeur de la constante », rouge à deux tests ; C-15-13-24) ; + **R4-2/F-P4-1** (contrôle de l'AC 11 f sur `target: /data/backup` ; T0 : cas « variable posée, + montage absent → 0 ») ; **F-P4-2** (fonction pure `source_conforme`, exercée par (S) avec `./backups` + refusé ; M14 désormais rouge ; C-15-13-24) ; **F-P4-3** (`.env.example:208-210`, `:241-242`, + `CHANGELOG.md:44`, `admin-manual.tex:812` à l'inventaire et aux AC 8 c, 11 d, 13 c, tâches T1, T6 ; + grep (4) du T9 **par la valeur** `\./(inbox|documents|log)\b` rejoué à `c702b7d5`, chaque site trié). + - **Décision de l'orchestrateur** (F-P4-6, C-15-13-21) : écriture sous `.partial` (`0600`, même dossier), + `sync_all`, puis `rename` ; refus d'écraser par vérification du nom final ; test + `write_backup_file_passe_par_un_partiel` et **M46** ; manuel : un `.partial` peut être supprimé ; + angles morts « `.partial` non supprimé », « fenêtre vérification → renommage », « durabilité du + renommage ». + - **LOW** : **F-P4-4** (négation exigée aux tests 15 et 16, **M45**) ; **F-P4-5** (texte qui ne + privilégie pas le dossier : deux pistes à égalité ou aucune, C-15-13-25) ; **F-P4-7** (`:1643` reçoit + une phrase d'exception ; `:1712` et `website/roadmap.html:231` assumés) ; **F-P4-8** (« depuis le + répertoire du compose », `lstlisting` à 76 caractères pour la ligne `sudo chown` de 74 ; copie de la + recette hors dépôt : l'orchestrateur réalignera sa mémoire) ; **R4-3** (`--no-index` au T1) ; **R4-4** + (jeton `deux gestes` restreint) ; **R4-5** (test 19 : « au moins quatre ») ; **R4-6** (= F-P4-2) ; + **R4-7** (« comme avant » au manuel, `/tmp` au seul CHANGELOG) ; **R4-8** (valeur absolue + `/tmp/kesh-e2e/backup` pour les trois recettes de `docs/testing.md`) ; **R4-9** (dossier témoin d'umask + au test 11) ; **R4-10** (références `:196-198`/`:199-207`, `ls-files` rejoué à `c702b7d5`, + supersession de « cinq modules » de C-15-13-9 dans C-15-13-26). + - **Règles changées** : (1) **l'écriture de la sauvegarde** passe par `.partial` + renommage (AC 9 a) — + le refus d'écraser change de mécanisme (vérification du nom final, M26 réécrite) ; (2) **le texte de + #576** n'oriente plus vers le seul dossier (AC 15 c) ; (3) **le défaut `/data/backup`** est désormais + lié par test au littéral et au montage (AC 7 a). Les deux premières touchent la production : une passe + P5 **complète** est recommandée, non une passe ciblée. + - **Recompte** (depuis cette fiche, `grep`) : **10 AC**, **9 tâches**, **11 lignes de test** portant + **13 fonctions Rust** (9 neuves, 4 existantes complétées) ; **22 mutations** (M9–M11, M14, M15, + M24–M26, M30–M41, M45, M46 ; `sort -u` → 22) ; **3 lignes** au tableau des cas ; **41 lignes** + d'inventaire ; **10 angles morts** ; **4 modules de code** (inchangé : `config`, `routes/admin`, + `errors`, catalogues `kesh-i18n`). Union avec la 15-13a : 46 mutations, M1 à M46 sans trou ni doublon. diff --git a/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p4.md b/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p4.md new file mode 100644 index 000000000..e9859ff14 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p4.md @@ -0,0 +1,35 @@ +# Prompt — validation P4 de la spec, Story 15-13b + +*Versionné le 2026-10-09. Deux lentilles (Sonnet), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md`. La dernière remédiation est le commit `a847f369` (`git show a847f369`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #552, #576. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + les fiches). **La 15-13 vient d'être découpée (C-15-13-16)** : index `15-13-mariadb-et-sauvegarde.md` (historique des passes P1-P3 ; version complète au commit `8a9bcd27`), sous-fiches `15-13a-mariadb-non-publiee-mots-de-passe-obligatoires.md` et `15-13b-sauvegarde-avant-import-persistante.md` — relis les deux pour la frontière. Choix C-15-13-1 à 20. Vérifie que rien n'est perdu au découpage (contre `git show 8a9bcd27:_bmad-output/implementation-artifacts/15-13-mariadb-et-sauvegarde.md`), et que chaque sous-fiche est vraie si elle est mergée SEULE (CHANGELOG `[0.13.0]`, manuel, tableau des gestes). **Axes prioritaires** : AC 8 e et le test 19 (motifs ANCRÉS `/backup/`, `/inbox/`, `/documents/`, tolérance `log/`, M41 ; `frontend/src/routes/(app)/admin/backup/` versionné) ; le tri pré/post-sauvegarde des 17 sites `AdminFullImportFailed` et la variante `AdminPreImportBackupFailed` ; l'AC 16 élargie au grep `cargo run` et les sites triés « assumés » ; l'« effet sans refus » conditionné au compose 0.13.0 ; le rapatriement `sudo cp` pour restaurer ; le test 16 (interdit de la promesse) ; le manuel, PDF aplati. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show a847f369`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13b-p4-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p5.md b/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p5.md new file mode 100644 index 000000000..ac6c27a14 --- /dev/null +++ b/_bmad-output/implementation-artifacts/15-13b-validate-prompt-p5.md @@ -0,0 +1,35 @@ +# Prompt — validation P5 de la spec, Story 15-13b + +*Versionné le 2026-10-09. Deux lentilles (Opus), contexte frais chacune, en lecture seule. Rotation (décision D6 de la +rétro 25) : passes complètes Sonnet ↔ Opus ; Haiku réservé à une passe ciblée de fin de boucle.* + +Dépôt `/home/gcorbaz/devel/kesh-15-13`, branche `story/15-13-mariadb-et-sauvegarde`. **Fiche** : `_bmad-output/implementation-artifacts/15-13b-sauvegarde-avant-import-persistante.md`. La dernière remédiation est le commit `3ebedca9` (`git show 3ebedca9`) — lis le Change Log de la fiche. +Issues, par `gh api repos/guycorbaz/kesh/issues/N` (et `/comments`) — `gh issue view` échoue ici : #552, #576. +Choix autonomes : `_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md`. Règles : `CLAUDE.md`. Code de référence : la branche elle-même (`de285ea8` + les fiches). Index `15-13-mariadb-et-sauvegarde.md`, sœur `15-13a-…`. Choix C-15-13-1 à 26. **Signal D5** : un MEDIUM de P4 (R4-2/F-P4-1) était né de la remédiation P3 ; l'orchestrateur n'a pas redécoupé (défaut isolé dans un texte, fiche déjà issue d'un découpage) — si cette passe trouve un défaut né de la remédiation P4, dis-le explicitement. **Axes prioritaires** : l'écriture par `.partial` puis `sync_all` puis `rename` (C-15-13-21) — atomicité, refus d'écraser (vérification du nom final avant `rename`, fenêtre écrite en angle mort), le `.partial` laissé en cas d'échec, test `write_backup_file_passe_par_un_partiel` et M46/M26 ; le texte de #576 (C-15-13-25) dans les 4 locales et le repli Rust ; la liaison de `DEFAULT_ADMIN_BACKUP_DIR` au littéral et au montage (tests 3 et 6 b, M15) ; `source_conforme` et M14 ; le contrôle `target: /data/backup` ; l'inventaire (grep (4) par la valeur) ; le manuel et son PDF aplati. +Aucun code de la story n'est encore écrit. + +## Lentilles + +- **R — Regression hunter** : la dernière remédiation (`git show 3ebedca9`) a-t-elle introduit des défauts ? Contradictions entre AC, tâches, Dev Notes et choix du registre ; frontière avec les fiches sœurs ; faits recopiés sans vérification (numéros de ligne, fonctions, codes, clés i18n) — revérifie-les au code (`grep -nF`) ; recompte des AC et tâches ; implémentable sans deviner ? +- **F — Full-scope adversary** : la conception elle-même. Les chemins de code qui écrivent ou lisent ce que la story + change (inventorier les sites NON résolus, pas énumérer ceux qui marchent) ; transactions, verrous, ordre des + contrôles ; tests existants qui changeront de sens ; frontend qui lit les codes ou champs touchés (`grep -rn` dans + `frontend/src`) ; i18n 4 locales et replis ; le **manuel** (`docs/manual/fr/*.tex` **et le PDF aplati** : + `pdftotext docs/manual/fr/user-manual.pdf - | tr '\n' ' ' | tr -s ' '` vers `target/gate-logs/`) ; + `docs/api-external.md` ; CHANGELOG ; la règle de découpage (§ « Règle de splitting préventif », amendement D5). + +## Ce que tu rends + +Findings numérotés avec sévérité (CRITICAL/HIGH/MEDIUM/LOW), `fichier:ligne` (fiche et code), **preuve** (commande et +sortie, ou code cité relu), correction proposée. Pour tout finding affirmant qu'un code est absent ou présent : la +sortie d'un `grep -nF`. ⛔ **La liste des axes exercés ET non exercés** — un « 0 finding » sans elle ne compte pas. +⛔ **Écris ton rapport complet** dans `target/gate-logs/15-13b-p5-.md` (R ou F ; autorisé), et rends +comme dernier message **seulement** : ce chemin, le bilan par sévérité, et une ligne par finding MEDIUM ou plus. + +## Interdits + +⛔ N'écris aucun fichier du dépôt hors `target/gate-logs/` ; aucune commande qui écrit dans le dépôt ou dans une +base : `scripts/*` (dont `scripts/prepare-release.sh`), `make`, `latexmk`, `git commit`/`add`/`checkout`/`stash`, +`sqlx`, `cargo test`/`nextest`/`build`, `npm run`, `npx`, `gh issue create`/`comment`/`edit`, aucune requête SQL +d'écriture. Autorisés : lecture, `grep`, `sed -n`, `git log`/`show`/`diff`, `gh api` en lecture, `pdftotext` +vers `target/gate-logs/`. diff --git a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md index ffaf5ace7..afadd0c33 100644 --- a/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md +++ b/_bmad-output/implementation-artifacts/epic-15-choix-autonomes.md @@ -5366,4 +5366,214 @@ l'import (#458–#461). - **Écartées** : réécrire le message (B-1) dans cette story — hors consigne, et l'issue #569 couvre la famille ; ajouter le `case 'LATER_FISCAL_YEAR_CLOSED'` au `switch` (E-3) — code de composant pour un comportement déjà correct. + +## C-15-13-1 — 15-13 (spécification) : le port de MariaDB n'est plus publié du tout, pas même en loopback + +- **Contexte** : `docker-compose.yml:13` publie `"3306:3306"` sur toutes les interfaces (#551). `kesh-api` atteint MariaDB par le réseau interne de Compose ; aucune commande du manuel n'emploie le port publié (toutes passent par `docker compose exec`). Un port publié par Docker contourne UFW, que le manuel recommande (`admin-manual.tex:1998-2009`). +- **Retenu** : aucune clé `ports:` au service `mariadb` ; un commentaire donne la forme loopback (`127.0.0.1:3306:3306`) à décommenter pour un dépannage ponctuel ; le test `configuration_transmise.rs` rougit sur toute clé `ports:` de `mariadb`. +- **Écartées** : `"127.0.0.1:3306:3306"` actif (inutile au fonctionnement, et une ouverture que personne n'a demandée) ; garder `3306:3306` en avertissant (l'issue est un P2 de sécurité). +- **Réversible** : oui (une ligne à décommenter). + +## C-15-13-2 — 15-13 (spécification) : les mots de passe MariaDB sont exigés par Compose (`${VAR:?message}`) ; Kesh avertit, sans refuser, sur un mot de passe publié + +- **Contexte** : défauts `kesh_dev_root` / `kesh_dev` dans `docker-compose.yml` (`:8`, `:11`, `:53`) et lignes **actives** de `.env.example` (`:336`, `:339`). Kesh ne voit pas le mot de passe root (non transmis, C71) ; refuser `kesh_dev` dans Kesh casserait `docker-compose.dev.yml`, le montage E2E et toutes les recettes de test, qui l'emploient ; `KESH_TEST_MODE` ne distingue pas (`TestModeWithPublicBind` interdit le mode test sur la pile de dev). +- **Retenu** : `${MARIADB_ROOT_PASSWORD:?…}` et `${MARIADB_PASSWORD:?…}` (y compris dans la `DATABASE_URL` composée) — Compose refuse de démarrer en nommant la variable ; lignes du gabarit commentées et sans valeur ; avertissement Kesh au démarrage si le mot de passe de `DATABASE_URL` est `kesh_dev` ou `kesh_dev_root` ; indice explicite sur l'erreur 1045 (le piège de la mise à jour : MariaDB ne lit `MARIADB_PASSWORD` qu'à la création de la base) ; CI qui exige le refus sans les variables. Le manuel écrit les anciennes valeurs (pour qu'une installation qui s'y appuyait redémarre à l'identique avant de les changer) ; les fichiers distribués n'en portent plus aucune trace. +- **Écartées** : refus Kesh de `kesh_dev` (casse la pile de dev et la CI) ; placeholder `` actif pour les mots de passe MariaDB (la base s'initialiserait avec le gabarit, et `<`, espace, `:` cassent la `DATABASE_URL` composée) ; message de refus qui inviterait à « choisir » un mot de passe (une valeur neuve dans `.env` sur une base déjà créée = panne 1045). +- **Réversible** : oui. + +## C-15-13-3 — 15-13 (spécification) : sauvegarde pré-import par défaut dans `/data/backup`, montée en dur sur `./backup` par les deux compose + +- **Contexte** : `KESH_ADMIN_BACKUP_DIR` vaut `/tmp` par défaut (`config.rs:947`), transmise en `${…:-}` sans aucun montage (#552). Le gabarit suggère déjà `#KESH_ADMIN_BACKUP_DIR=/data/backup`, que rien ne monte. +- **Retenu** : défaut du code `/data/backup` (constante nommée), comme `/data/documents` et `/data/inbox` ; montage fixe `./backup:/data/backup` dans `docker-compose.yml` et `docker-compose.prod.yml` ; transmission `${KESH_ADMIN_BACKUP_DIR:-}` inchangée (C75, liste `AJOUTS` intacte) ; `MONTAGES` du test étendu, égalité exigée pour une source fixe. Singulier, pour que l'exploitant qui a décommenté la suggestion du gabarit soit couvert sans rien changer. +- **Écartées** : défaut de déploiement dans le compose (`${KESH_ADMIN_BACKUP_DIR:-/data/backup}`, contraire à C75 et sans effet pour un compose tiers) ; ranger les sauvegardes sous `/data/documents` ou `/var/log/kesh` (dossiers que le manuel invite à partager en SMB, et le fichier est un secret) ; variable d'hôte `KESH_BACKUP_HOST_DIR` (relève de #558) ; `/data/backups` au pluriel (laisserait dans le conteneur l'exploitant qui a suivi le gabarit). +- **Réversible** : oui (aucune donnée déplacée : l'ancien emplacement mourait avec le conteneur). + +## C-15-13-4 — 15-13 (spécification) : la sauvegarde pré-import est écrite en `0600`, sans écrasement, dossier créé en `0700` + +- **Contexte** : rendre la sauvegarde persistante sur l'hôte change sa durée de vie ; le `.keshbackup` contient condensés de mots de passe et jetons de session ; le dossier d'hôte créé par Docker est `root:root` `0755`, et `tokio::fs::write` crée le fichier selon l'umask (`0644`). +- **Retenu** : `OpenOptions` avec `create_new(true)` et `mode(0o600)` (unix), `sync_all` avant le journal ; `DirBuilder` récursif en `0o700` quand Kesh crée le dossier ; un dossier existant n'est pas modifié. Test avec assertion de montage sur l'umask. +- **Écartées** : chiffrer la sauvegarde (changement de format, hors périmètre) ; `chmod` du dossier de l'hôte par Kesh (il appartient à l'exploitant). +- **Réversible** : oui. + +## C-15-13-5 — 15-13 (spécification) : périmètre du manuel — `openssl rand -hex 32` pour MariaDB, `exec db` corrigé, section Synology laissée à une issue + +- **Contexte** : le manuel fait générer le mot de passe MariaDB de Synology en `openssl rand -base64 32` (`admin-manual.tex:546`), repris dans `DATABASE_URL` — `/` y apparaît une fois sur deux et casse l'URL. Trois commandes visent un service `db` qui n'existe pas (`:1460`, `:1511`, `:2216`) avec un `-p"${MARIADB_ROOT_PASSWORD}"` que rien ne pose. La section Synology (pré-script Hyper Backup `:1578`, aperçu Container Manager `:571`) suppose un service `mariadb` que `docker-compose.prod.yml` n'a pas ; le nom de volume `kesh_db_data` (`:1422`, `:1744`) est faux. +- **Retenu** : traités ici, parce qu'ils sont le sujet même de la story (accès à MariaDB, mot de passe MariaDB) : `hex` partout pour MariaDB ; `exec mariadb` avec le mot de passe lu dans le conteneur. **Non traités**, signalés pour une issue P3 : la section Synology sans service `mariadb`, le nom de volume. +- **Écartées** : réécrire la section Synology (autre sujet, autre installation, risque de déborder) ; laisser `base64` (défaut actif sur l'installation de référence). +- **Réversible** : oui (texte). +- **Rectificatif** (validation P2, 2026-10-09) : l'aperçu Container Manager est à `admin-manual.tex:573`, non `:571`. + +## C-15-13-6 — 15-13 (spécification) : l'emplacement de la sauvegarde n'est pas renvoyé par l'API ni affiché à l'écran + +- **Contexte** : #552 demande que « le manuel dise où les trouver ». La réponse de l'import ne porte que `backupCreated`. +- **Retenu** : le manuel dit l'emplacement (`./backup` du dossier du compose, nom de fichier) ; ni l'API ni l'écran ne changent. +- **Écartées** : ajouter le nom du fichier à la réponse et l'afficher (frontend, i18n ×4, contrat d'API : trois modules de plus pour une information que l'exploitant trouve au manuel ; un chemin du conteneur affiché à l'écran serait d'ailleurs trompeur — ce n'est pas celui de l'hôte). +- **Réversible** : oui. + +## C-15-13-7 — 15-13 (spécification) : une seule story pour #551 et #552, pas de découpage ; ligne du registre et paragraphe de la fiche d'epic + +- **Contexte** : règle de découpage préventif (plus de cinq modules). Les deux issues touchent les mêmes fichiers (compose, gabarit, test `configuration_transmise.rs`, manuel § « Passer à la 0.13.0 », CHANGELOG `[0.13.0]`). Le worktree part d'`origin/main` (`de285ea8`), où la fiche d'epic ne porte pas encore le paragraphe « Story 15-13 — à spécifier » écrit sur la branche de planification du dépôt principal. +- **Retenu** : une story, trois modules de code dans un crate (`config`, `main`, `routes/admin`) — même décompte que la 15-11a ; ligne `15-13-mariadb-et-sauvegarde: ready-for-dev` (convention « en validation » de l'epic) ajoutée après la 15-11b ; paragraphe « Story 15-13 » ajouté à la fiche d'epic après celui de la 15-11 — à fusionner par union avec la branche de planification. +- **Écartées** : deux stories (#551 / #552), qui doubleraient les conflits sur les mêmes sections sans réduire le risque. +- **Réversible** : oui (planification). +- **Décompte supersédé** (ajout de la validation P2, 2026-10-09) : « trois modules » ne vaut plus depuis l'absorption de #576 ; le décompte en vigueur est celui de **C-15-13-9** (cinq modules, seuil atteint, non franchi). La décision « pas de découpage » tient sur ce nouveau décompte. + +## C-15-13-8 — 15-13 (validation P1) : la sauvegarde pré-import reste en `0600` ; le manuel donne le geste de rapatriement + +- **Contexte** : R-2 / F4 de la validation P1. L'import se fait par le navigateur ; un fichier `root` en `0600` sur l'hôte ne se lit ni par File Station, ni par SMB, ni par un `scp` sans `sudo`. La procédure « restaurer depuis la sauvegarde = l'importer par le même écran » échouait au moment où l'on en a besoin. L'affirmation « lisible du seul propriétaire » n'était pas mesurée sur Synology (ACL du dossier partagé). +- **Retenu** (décision de l'orchestrateur) : le mode `0600` reste — la sauvegarde contient toute la comptabilité et ses secrets ; le manuel donne le rapatriement en SSH (`sudo cp` vers un dossier partagé, `sudo chown`, import, suppression de la copie), rejoué sur un conteneur jetable au T0 ; « créé en mode `0600`, propriétaire `root` », avec la limite ACL écrite non mesurée ; le tableau des cas gagne « restaurer après un import raté ». +- **Écartées** : un mode `0640`/`0644` ou un `chown` vers un utilisateur de l'hôte (élargit l'accès à un secret, et l'UID de l'exploitant n'est pas connu du conteneur) ; renvoyer le fichier par l'API (C-15-13-6). +- **Réversible** : oui (mode et texte du manuel). + +## C-15-13-9 — 15-13 (validation P1) : #576 absorbée — variante `AdminPreImportBackupFailed`, conversion à l'appel, cinq modules + +- **Contexte** : F7 de la validation P1. Avec le défaut `/data/backup`, toute instance lancée hors Docker (montage E2E du dépôt, développement) refuse chaque import, et le message affiché annonçait « un backup automatique a été créé ». La story faisait de ce message faux le cas nominal. L'orchestrateur a décidé que la story ferme #576. +- **Retenu** : une variante neuve (500, code `ADMIN_PRE_IMPORT_BACKUP_FAILED`, clé `error-admin-pre-import-backup-failed`, quatre locales et repli Rust) pour **tout** échec antérieur à l'écriture réussie — y compris transaction et verrou, dont le message mentait aussi ; les deux variantes nées dans `admin_backup` (`check_schema_compat`, `build_keshbackup`) converties **à l'appel** par une fonction pure `avant_sauvegarde`, pour ne pas toucher `admin_backup` ; le texte d'`error-admin-full-import-failed` inchangé (il dit vrai pour les douze sites postérieurs). Les recettes `cargo run` du dépôt posent `KESH_ADMIN_BACKUP_DIR` (`CLAUDE.md` : la seule ligne de commande, `docs/testing.md`). Décompte : cinq modules (`config`, `main`, `routes/admin`, `errors`, catalogues `kesh-i18n`) — seuil atteint, non franchi. +- **Écartées** : modifier `admin_backup/import.rs` et `export.rs` (sixième module, seuil franchi) ; un champ « étape » dans `AdminFullImportFailed` (même texte pour deux vérités différentes, à trier par le client) ; un contrôle du dossier au démarrage (`warn!`) — le message dit la cause au moment où elle compte, et un dossier monté tardivement le ferait mentir ; une story séparée pour #576 (elle naît du changement de défaut de #552, la séparer laisserait la 15-13 livrer un message faux). +- **Réversible** : oui (une variante et une clé). + +## C-15-13-10 — 15-13 (validation P1) : le contrôle de fin de mise à jour se connecte à la base avec les anciens mots de passe + +- **Contexte** : F3 de la validation P1. La vérification prévue (`docker compose config | grep kesh_dev`) ne lit que `.env` : un mot de passe root neuf écrit sans `ALTER USER` la rend muette alors que la base garde `kesh_dev_root`, et fait échouer la sauvegarde nocturne sans que rien ne rougisse (Kesh n'utilise pas root, le healthcheck passe par le compte `healthcheck`). La phrase « aucun de ces cas ne casse en silence » était fausse. +- **Retenu** (décision de l'orchestrateur) : connexion avec les **anciens** mots de passe publiés → refus attendu ; avec ceux de `.env` → succès ; pour `root` et `kesh`, en TCP (`--protocol=TCP -h 127.0.0.1`) pour que l'authentification par socket de `root@localhost` ne fausse pas la mesure — forme exacte rejouée au T7. Plus : lancer une fois à la main le script de sauvegarde après toute mise à jour ou tout changement de mot de passe. Le tableau des cas gagne la ligne « root neuf sans `ALTER USER` » et la conclusion est réécrite (deux cas silencieux nommés, avec leur geste de détection). +- **Écartées** : garder le `grep` (faux négatif sur le cas même qu'il devait couvrir) ; transmettre `MARIADB_ROOT_PASSWORD` à Kesh pour qu'il vérifie (C71 : pas d'`env_file`, et Kesh n'a pas à connaître root). +- **Réversible** : oui (texte du manuel). + +## C-15-13-11 — 15-13 (validation P1) : `init-demo.sh` lit l'identifiant MariaDB dans le conteneur + +- **Contexte** : R-3 / F6. `init-demo.sh` (versionné) vise par défaut `kesh-mariadb`, le conteneur de `docker-compose.yml`, avec `kesh_dev` en dur : après la story, il échoue en 1045 sur toute installation conforme ; c'était aussi le dernier outil distribué qui supposait le mot de passe publié. L'inventaire « fermé » l'avait omis. +- **Retenu** : `docker exec -i "$CONTAINER" sh -c 'mariadb -u "$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'` — les deux compose à service MariaDB posent ces variables dans le conteneur ; en-tête qui dit le caractère destructif et l'administrateur de démonstration ; vérifié contre le conteneur jetable du T7 (jamais `kesh-mariadb-dev`, dont la base `kesh` est partagée). +- **Écartées** : le marquer « développement seulement » sur `kesh-mariadb-dev` (il resterait faux pour sa cible par défaut) ; le supprimer (outil documenté par son en-tête, décision de produit hors story). +- **Réversible** : oui. +- **Remplacé** (validation P2, 2026-10-09) : par **C-15-13-13** — le script est retiré du dépôt ; la lecture de l'identifiant dans le conteneur le rendait opérant, sans transaction ni confirmation, sur toute installation conforme. + +## C-15-13-12 — 15-13 (validation P1) : forme du message `:?` et scission de l'interpolation obligatoire + +- **Contexte** : R-1 / F1 et F2. Le message prescrit contenait `": "`, qui rend les deux compose invalides en YAML (scalaire non cité) ; et le test confondait `${VAR:?}` (refuse le vide) et `${VAR?}` (ne refuse que l'absence), si bien qu'une ligne `MARIADB_PASSWORD=` vide passait. +- **Retenu** : deux formes admises — message sans `": "` ni `" #"`, ou valeur entière entre guillemets doubles —, tranchées au T0 par `docker compose config -q` **avec** les variables posées ; message qui dit d'abord le cas existant, puis le neuf ; `Interpolation::Obligatoire` scindée en `ObligatoireNonVide` / `ObligatoireSiAbsente`, la première exigée (mutations M28, M29) ; contrôle de la `DATABASE_URL` par resserrement du motif de `VALEURS_COMPOSEES` (DRY). +- **Écartées** : guillemets simples (l'apostrophe de « d'administration ») ; un message sans indication pour l'installation existante (il pousserait à écrire une valeur neuve). +- **Réversible** : oui. + +## C-15-13-13 — 15-13 (validation P2) : `init-demo.sh` est retiré du dépôt, pas corrigé + +- **Contexte** : F2 de la validation P2. La correction de la P1 (C-15-13-11 : identifiant lu dans le conteneur) rendait le script **opérant** sur toute installation conforme ; or il enchaîne des `DELETE` (`init-demo.sh:57-62`) sans transaction ni confirmation, et sur une base peuplée `DELETE FROM companies` échoue sur les 25 clés étrangères `ON DELETE RESTRICT` **après** que `users` et `onboarding_state` ont été vidés. L'orchestrateur a demandé de vérifier d'abord s'il est redondant. +- **Constat** : redondant. La démonstration est semée par l'application (`POST /api/v1/onboarding/seed-demo`, `routes/onboarding.rs:177-190` → `kesh_seed::seed_demo` : plan comptable par les repositories, exercice, `is_demo`, réinitialisation par `kesh_seed::reset_demo`). Le script écrit en SQL brut dix comptes sans rôle, sans exercice, et un administrateur `admin`/`admin123`. `git grep -n init-demo` hors `_bmad-output` ne rend que le fichier lui-même : aucun script, aucune CI, aucune documentation ne l'appelle (dernier commit qui le touche : `b63dc4e1`, Story 7-1). +- **Retenu** : `git rm init-demo.sh` (AC 12 b) ; entrée « Retiré » au CHANGELOG ; le T7 ne le lance plus ; inventaire « résolu par retrait ». +- **Écartées** : le garder en le durcissant (refus si la base porte des données, `START TRANSACTION`, test T7 sur un conteneur peuplé) — maintient un second chemin de démonstration, hors des repositories, que rien n'appelle et qui dérive du schéma à chaque migration ; le garder tel que la P1 l'avait corrigé (risque F2 entier). +- **Réversible** : oui (l'historique git garde le fichier). + +## C-15-13-14 — 15-13 (validation P2) : les dossiers montés par défaut sont ignorés de git et du contexte de build, contrôle dérivé de `MONTAGES` + +- **Contexte** : F1 de la validation P2. `docker compose up` se lance depuis un clone ; `./backup` y apparaît au premier import et contient un secret. Grepé par la valeur, le symptôme vaut aussi pour `./inbox` et `./documents` (justificatifs) : seul `log/` est dans `.gitignore`, aucun des quatre dans `.dockerignore`. +- **Retenu** : `.gitignore` gagne `/inbox/`, `/documents/`, `/backup/` (ancrés à la racine ; `log/` gardé tel quel) ; `.dockerignore` gagne `log/`, `inbox/`, `documents/`, `backup/`. Un test (`montages_hors_du_depot`, test 19) **dérive** la liste des dossiers des sources de `MONTAGES` (colonne `docker-compose.prod.yml`), si bien qu'un montage ajouté demain est contrôlé sans retouche ; mutations M39, M40. `git ls-files` sous ces dossiers : 0 (aucun contenu masqué). +- **Écartées** : `backup/` seul (laisse le même défaut sur deux dossiers voisins — propagation par le symptôme, `CLAUDE.md`) ; motifs non ancrés (`documents/` masquerait tout sous-dossier homonyme du dépôt). +- **Réversible** : oui. + +## C-15-13-15 — 15-13 (validation P2) : `docs/ci.md` — le décompte des jobs est corrigé, la description d'un job `e2e` inexistant est laissée à une issue + +- **Contexte** : F6 de la validation P2. `docs/ci.md:9-10` annonce « 4 jobs (`backend`, `frontend`, `e2e`, `docker-build`) » ; `ci.yml` en a trois. Grepé par la valeur, le job `e2e` est décrit aussi à `:23`, `:30`, `:131`, `:164`, `:186`, et le `CLAUDE.md` affirme un smoke E2E en CI que `ci.yml` ne porte pas. +- **Retenu** : la story corrige `:9-10` (à une ligne de `:12`, qu'elle réécrit déjà) et ajoute sous `:10` une phrase de renvoi vers une issue que l'orchestrateur ouvre ; les autres lignes et le `CLAUDE.md` restent (défaut antérieur, hors du sujet de #551/#552/#576, et la recette du `CLAUDE.md` est la seule ligne que la story y touche). +- **Écartées** : réécrire toute la description de la CI (hors sujet, et elle dépend d'une décision — rétablir le job ou retirer sa description) ; ne rien toucher (la story réécrit `:12` à côté d'un décompte qu'elle saurait faux). +- **Réversible** : oui. + +## C-15-13-16 — 15-13 (validation P3) : découpage en 15-13a (MariaDB, #551) et 15-13b (sauvegarde, #552 et #576) + +- **Contexte** : validation P3 (deux lentilles Opus 5.5) : 5 MEDIUM distincts, dont trois nés de la remédiation P2 (R3-1, R3-2, F-P3-2), après deux nés de la remédiation P1 en P2. Signal D5 de recyclage levé deux passes de suite — la forme qui découpe selon l'amendement D5 de la rétrospective de l'Epic 25. +- **Retenu** (décision de l'orchestrateur) : deux fiches filles selon la couture naturelle — **15-13a** « MariaDB non publiée, mots de passe obligatoires » (`closes #551`, refs #577 ; modules `config`, `main`) et **15-13b** « sauvegarde avant import persistante » (`closes #552`, `closes #576`, refs #558 ; modules `config`, `routes/admin`, `errors`, catalogues `kesh-i18n`) ; `15-13-mariadb-et-sauvegarde.md` devient fiche index (`split`), la version complète restant au commit `8a9bcd27`. Numérotation de la fiche unique conservée dans les filles (AC, tests, mutations, tâches) ; recompte aux deux bornes écrit dans l'index. Ordre suggéré : 15-13a d'abord (#551 P2) ; chaque fille ne réécrit que ce que son changement rend faux, si bien qu'une seule mergée au tag laisse un CHANGELOG et un manuel vrais ; la seconde recompte les gestes de `admin-manual.tex:1793` et de `CHANGELOG.md:46`. +- **Écartées** : une troisième fiche pour #576 (elle naît du défaut `/data/backup` : la séparer de #552 laisserait un message faux dans le cas nominal hors Docker, C-15-13-9) ; une coupe « code / documentation » (chaque moitié ne serait ni livrable ni testable seule) ; continuer sans découper (troisième recyclage probable : les défauts naissent désormais dans les ajouts de la passe précédente). +- **Réversible** : oui (planification ; les deux fiches se refondent par simple concaténation, la numérotation étant commune). + +## C-15-13-17 — 15-13a (validation P3) : Kesh avertit aussi pour un mot de passe applicatif resté au gabarit du manuel + +- **Contexte** : F-P3-3. Le manuel fait recopier `MARIADB_PASSWORD=` (`admin-manual.tex:254`) et `DATABASE_URL=mysql://kesh:@…` (`:259`) ; recopiées telles quelles, ces chaînes — imprimées dans le PDF distribué — deviennent le mot de passe applicatif. La fiche les disait « hors de portée de Kesh pour la même raison » que root ; c'est faux : Kesh lit `DATABASE_URL`, et `is_template_placeholder` (`config.rs:1405`, 15-11a) existe. +- **Retenu** (décision de l'orchestrateur) : AC 5 a-bis — un `warn!` **distinct** quand le mot de passe décodé satisfait `is_template_placeholder` (réemploi, DRY), renvoi à la procédure de changement (AC 11 g) ; test 7 étendu (formes encodée et non encodée, chevrons intérieurs en témoin), mutation **M42** ; doc-comment du prédicat complété ; tableau des cas et Dev Notes rectifiés. Le **root** gabarit reste un angle mort (Kesh ne voit pas root, C71), tracé par une issue que l'orchestrateur ouvre. +- **Écartées** : refuser le démarrage (même raison que C-15-13-2 : un mot de passe faible n'empêche pas de fonctionner, et le refus casserait des installations existantes sans gain sur root) ; un message commun avec `kesh_dev` (l'exploitant ne saurait pas que la valeur vient du manuel) ; un second prédicat propre aux mots de passe (duplication). +- **Réversible** : oui. + +## C-15-13-18 — 15-13a et 15-13b (validation P3) : les gestes « pour qui garde son compose » se comptent par fichier ; le contrôle des placeholders du CHANGELOG suit le manuel + +- **Contexte** : F-P3-1 et F-P3-2. `admin-manual.tex:1793` (« Pour qui garde son fichier compose : deux gestes. », puis « Sous `environment:` du service `kesh-api` : ») et `CHANGELOG.md:46` (« décrit les deux gestes ») deviennent faux : la 15-13 ajoute des gestes hors d'`environment:`, différents selon le fichier. `CHANGELOG.md:52` recopie le `docker compose config | grep` « doit rester muette », piège corrigé au manuel en P2 (R2-9) mais non propagé. +- **Retenu** : le titre perd son décompte global ; le paragraphe donne le nombre **par fichier**, recompté par la fille mergée en second (aujourd'hui 2 / 2 ; 15-13a seule 4 / 2 ; 15-13b seule 3 / 3 ; les deux 5 / 3), chaque geste écrit avec son fichier et une ligne de contrôle ; `deux gestes` devient un contrôle négatif du PDF aplati. `CHANGELOG.md:52` reçoit la même forme que le manuel (mots de passe d'abord, `config -q && echo 'compose lisible'`) ou un renvoi au manuel — choix laissé au développement, écrit au Dev Agent Record. Les « Effets sans refus » de la sauvegarde sont conditionnés au compose (0.13.0 : `./backup` ; compose gardé : `/data/backup` du conteneur, éphémère). +- **Écartées** : un nombre unique (faux pour l'un des deux fichiers) ; garder « deux gestes » en ajoutant un troisième paragraphe (le titre resterait faux) ; supprimer la commande du CHANGELOG sans renvoi (perte de l'action requise). +- **Réversible** : oui (texte). + +## C-15-13-19 — 15-13b (validation P3) : motifs ancrés dans `.gitignore`, une seule tolérance nommée (`log/`) + +- **Contexte** : R3-1. Le test 19 admettait `x/` aussi bien que `/x/` pour toute entrée de `MONTAGES`, alors que C-15-13-14 écarte les motifs non ancrés. Mesuré : `backup/` ignore `frontend/src/routes/(app)/admin/backup/` (versionné : l'écran de la sauvegarde) — tout fichier neuf de cette route serait ignoré en silence ; `/backup/` non. +- **Retenu** : `.gitignore` exige `/x/` ; **seule** tolérance, une constante qui ne compte que `log` : la ligne `log/` existante (`.gitignore:34`) — aucun dossier `log` versionné nulle part (`git ls-files | grep -E '(^|/)log/'` → 0), et l'ancrer ferait réapparaître des `log/` d'outils de dev sous les crates ; un `log/` versionné ajouté demain serait masqué : risque écrit. Mutation **M41** (`/backup/` → `backup/`). `.dockerignore` inchangé dans sa forme (motifs relatifs à la racine du contexte). +- **Écartées** : ancrer aussi `log/` (change un comportement existant hors du sujet de la story) ; tolérer `x/` pour tous (le défaut mesuré). +- **Réversible** : oui. + +## C-15-13-20 — 15-13 (validation P3) : rectificatifs de C-15-13-13, C-15-13-14 et C-15-13-15 ; attribution de M5 + +- **Rectificatifs** (les entrées d'origine ne sont pas réécrites) : **C-15-13-13** — les `DELETE` d'`init-demo.sh` sont à `:70-75` (commentaire `:70`, instructions `:71-75`), non `:57-62` (`check_container()`, `:55-63`) — R3-7. **C-15-13-14** — l'homonyme réel d'un motif non ancré est `frontend/src/routes/(app)/admin/backup/` (versionné), non un hypothétique `documents/` ; la décision d'ancrer est tenue par C-15-13-19 — R3-1. **C-15-13-15** — l'issue à laquelle renvoie `docs/ci.md` existe : **#577** ; la liste des lignes qui décrivent le job `e2e` absent compte aussi `:210` — R3-3/F-P3-4, R3-5. +- **Attribution de M5** (constat du remédiateur) : `VALEURS_COMPOSEES` n'est lu que par `controle_valeur` (`configuration_transmise.rs:592-603`), donc par le test `valeurs` (famille (V), `:1743`) ; la fiche unique rangeait son resserrement sous `transmission` et M5 sous le test 1. Retenu : ligne 3a de la 15-13a (`valeurs`, existant modifié) porte M5 ; le décompte des fonctions passe de 19 à 20 (fonction modifiée jamais comptée). Écarté : faire porter le contrôle de la `DATABASE_URL` par le test neuf `mariadb` (second contrôle sur la même valeur, contraire au DRY de l'AC 10 a). +- **Réversible** : oui. + +## C-15-13-21 — 15-13b (validation P4) : la sauvegarde pré-import s'écrit sous `.partial`, puis est renommée + +- **Contexte** : F-P4-6. `write_backup_file` créait le fichier au nom final puis écrivait : un client déconnecté (hyper abandonne le futur du handler), un OOM ou un `docker stop` en cours d'écriture laissaient un `kesh-pre-import-….keshbackup` tronqué — désormais **persistant**, de même nom qu'une sauvegarde valide, et refusé en « corrompu » à l'import, au moment même où l'on en a besoin. +- **Retenu** (décision de l'orchestrateur) : écriture dans `.partial` du **même dossier** (`create_new`, `0600`), `sync_all`, puis `rename` vers le nom final (atomique sur un même système de fichiers) ; un arrêt pendant l'écriture ne laisse qu'un `.partial`, jamais un fichier nommé comme une sauvegarde. `rename` remplaçant une cible existante sur Unix, le refus d'écraser passe par une vérification du nom final avant le renommage (`try_exists`) ; la fenêtre entre les deux reste ouverte, sans portée pratique (nom unique par construction) — angle mort écrit. Test `write_backup_file_passe_par_un_partiel` (un `.partial` préexistant fait échouer l'appel, le nom final n'apparaît pas) ; mutation **M46** « écrire directement le nom final » ; M26 réécrite (« vérification du nom final retirée »). Le manuel dit qu'un `.partial` est une écriture interrompue et peut être supprimé. +- **Écartées** : écrire au nom final et documenter l'angle mort (le fichier mentirait sur sa nature) ; `hard_link` sans écrasement puis suppression du `.partial` (atomique, mais dépend du système de fichiers du montage — NAS, SMB) ; `renameat2(RENAME_NOREPLACE)` (hors `std`, Linux seulement). +- **Réversible** : oui (une fonction privée). + +## C-15-13-22 — 15-13a (validation P4) : le mot de passe de `DATABASE_URL` est décodé par `decode_utf8_lossy`, et sa non-divulgation est gardée par mutation + +- **Contexte** : F4 et F6. `percent_decode_str` rend un `PercentDecode` ; `decode_utf8()` renvoie une erreur sur des octets non UTF-8 (`%FF`), qu'un `?` ou un `unwrap` transformerait en échec ou en panique du démarrage — contraire à l'AC 5 b (« aucun `ConfigError` neuf »). Et la propriété « ni le mot de passe ni l'URL dans le journal » n'était gardée par aucune mutation. +- **Retenu** : `decode_utf8_lossy()` (une valeur non UTF-8 n'est ni publiée ni gabarit ; sa forme décodée ne déclenche rien) ; témoin `%FF` au test 7 ; **M43** (l'avertissement interpole le mot de passe décodé, rouge au test 7) et **M44** (le message d'échec de connexion interpole `database_url`, rouge au test 10, qui exige l'absence de `mauvais-15-13` dans la sortie). La forme non encodée du gabarit, établie à la source (`url` 2.5.8), devient un cas positif ferme du test 7 ; le repli conditionnel est retiré. +- **Écartées** : `decode_utf8()` avec gestion explicite de l'erreur (même effet, une branche de plus à tester) ; garder le repli du test 7 (branche morte qui laisse croire à une incertitude levée). +- **Réversible** : oui. + +## C-15-13-23 — 15-13a (validation P4) : la recette de changement de mot de passe MariaDB n'expose pas le nouveau mot de passe, et commence par arrêter Kesh ; la rubrique « Retiré » du CHANGELOG est légitime + +- **Contexte** : F7. La recette (`ALTER USER`, puis `.env`, puis `up -d`) ne disait pas comment le nouveau mot de passe atteint la requête : tapé en ligne de commande, il resterait dans l'historique du shell et dans la liste des processus de l'hôte, alors que toute la story lit les mots de passe dans le conteneur. Entre l'`ALTER USER kesh` et le `up -d`, les connexions neuves de Kesh échouent en 1045. Annexe : la rubrique « Retiré » n'existe dans aucune version du CHANGELOG. +- **Retenu** : nouveau mot de passe engendré dans une variable (`NEW=$(openssl rand -hex 32)`, jamais tapé), transmis à `mariadb` par l'entrée standard (document en ligne), reporté dans `.env` à l'éditeur ; la recette commence par `docker compose stop kesh-api` ; forme de référence en Dev Notes, forme finale fixée au T7. Rubrique « Retiré » gardée : *Removed* est l'une des six rubriques de Keep a Changelog 1.1.0 (vérifié en ligne le 2026-10-09), que l'en-tête du CHANGELOG déclare suivre en traduisant les intitulés. +- **Écartées** : `sed -i` pour écrire `.env` (le mot de passe passerait un instant dans les arguments de `sed`) ; laisser la forme au seul T7 (l'exigence d'hygiène doit être écrite avant la mesure, sinon la mesure ne la vérifie pas) ; ranger le retrait d'`init-demo.sh` sous « Modifié » (moins exact). +- **Réversible** : oui (texte du manuel et du CHANGELOG). + +## C-15-13-24 — 15-13b (validation P4) : le défaut `/data/backup` est lié par test au littéral et au montage ; la comparaison des sources de montage devient une fonction pure + +- **Contexte** : R4-1 et F-P4-2. `DEFAULT_ADMIN_BACKUP_DIR` n'était comparée qu'à elle-même : changée en `/data/backups`, elle laissait tous les tests verts et la sauvegarde retournait dans le conteneur ; M15, appliquée à la valeur de la constante, restait verte. La règle d'égalité des sources de montage vivait dans la boucle de `controle_transmission`, que l'auto-test (S) n'atteint pas : M14 n'avait aucun test capable de rougir. +- **Retenu** : constante `pub` ; le test 6 b la compare au **littéral** `"/data/backup"` (le seul littéral voulu côté test), le test `transmission` exige qu'elle soit la cible d'une entrée de `MONTAGES` (donc d'un montage des compose) ; M15 réécrite « valeur de la constante changée en `/tmp` », rouge aux tests 3 et 6 b. Fonction pure `source_conforme(compose, source, attendue_y, exacte_p) -> bool`, appelée par la boucle et exercée par (S) avec `./backups` refusé en `Y` — rouge sous M14. +- **Écartées** : faire porter le lien par le test 19 (il traite d'exclusion git, pas de montage) ; construire des `Service` synthétiques pour atteindre la boucle (plus lourd, même garantie). +- **Réversible** : oui. + +## C-15-13-25 — 15-13b (validation P4) : le message de #576 n'oriente pas vers le seul dossier, et sa négation est exigée par test + +- **Contexte** : F-P4-5 et F-P4-4. Trois des cinq échecs antérieurs à l'écriture sont des pannes de base (transaction, verrou, lecture du schéma ou des données) ; un texte « dossier de sauvegarde inscriptible ? » oriente vers une fausse piste. Et les tests 15/16 n'exigeaient que l'absence de l'ancienne promesse : un texte « Échec de l'import. » passait, sans plus dire qu'aucune copie n'existe — l'objet même de #576. +- **Retenu** : texte qui nomme les deux pistes à égalité (« dossier de sauvegarde inscriptible, base de données accessible ») ou aucune ; code et variante inchangés. Les tests 15 et 16 exigent la négation (« aucune sauvegarde n'a été créée » et ses trois traductions, à la lettre) ; mutation **M45**. +- **Écartées** : deux variantes et deux textes (dossier / base) — un module de plus à toucher pour un tri que les journaux font déjà ; un code d'erreur neuf pour la base (même raison). +- **Réversible** : oui (quatre catalogues et un repli). + +## C-15-13-26 — 15-13 (validation P4) : rectificatifs de C-15-13-9 et de C-15-13-14 + +- **Rectificatifs** (les entrées d'origine ne sont pas réécrites) : **C-15-13-9** — « cinq modules » ne vaut plus depuis le découpage (C-15-13-16) : la 15-13a en compte deux (`config`, `main`), la 15-13b quatre (`config`, `routes/admin`, `errors`, catalogues `kesh-i18n`) — R4-10 de la validation P4 de la 15-13b. **C-15-13-14** — « un montage ajouté demain est contrôlé sans retouche » n'était vrai que si l'assertion de montage du test 19 ne fixait pas le nombre d'entrées : elle exige désormais « au moins quatre », dont `/data/backup` (R4-5). **Inventaire de la 15-13a** — la ventilation « 29 + 6 → 36 » de la fiche index ne se refaisait pas ; seuls les totaux sont gardés (R4-7). +- **Réversible** : oui. + +## C-15-13a-1 — 15-13a (T0) : le manuel dit le refus de Compose tel qu'il est mesuré — `config`, `pull` et `up` refusent, `ps`, `logs`, `exec`, `stop` restent utilisables, et la variable nommée change d'un lancement à l'autre + +- **Contexte** : l'AC 11 f affirmait, sous réserve de mesure au T0, que « le refus de Compose frappe toute sous-commande » (`ps`, `logs`, `exec`, `stop`, `down`) et que le script de sauvegarde lancé par `cron` échoue dès que `.env` perd une des deux lignes ; l'AC 11 j, que Compose nomme « la première » variable et que « la seconde apparaît au lancement suivant ». Mesuré le 2026-10-09 (Docker Compose 2.40.3, projet jetable `kesh1513a-t0`, service `mariadb` en marche, `env -u MARIADB_ROOT_PASSWORD -u MARIADB_PASSWORD`) : `config`, `pull`, `up -d` et `up -d kesh-api` sortent en code 1 avec `required variable … is missing a value` ; `ps`, `logs`, `exec -T mariadb true` et `stop` sortent en 0 et font leur travail. Et, sur 12 lancements de la même commande, la variable nommée varie : `MARIADB_ROOT_PASSWORD`, `MARIADB_PASSWORD` ou `DATABASE_URL` (de `kesh-api`, qui nomme `MARIADB_PASSWORD`) — ordre non déterministe. +- **Retenu** : le manuel, le CHANGELOG et `DOCKER_START.md` disent la mesure — refus de `config`, `pull`, `up` ; `ps`, `logs`, `exec`, `stop` encore utilisables avec la version mesurée (diagnostic et arrêt possibles ; le script de sauvegarde, qui passe par `exec`, ne dépend pas de `.env`, il lit le mot de passe dans le conteneur) ; Compose nomme **une** variable à la fois, **pas toujours la même** : poser les deux avant de relancer. La version mesurée est nommée, faute de pouvoir garantir les autres. +- **Écartées** : écrire l'affirmation de la fiche (fausse sur la version mesurée) ; ne rien dire des sous-commandes (l'exploitant face au refus doit savoir s'il peut encore diagnostiquer). +- **Réversible** : oui (texte). La règle (refus par Compose, avertissement par Kesh) n'est pas touchée : seul le constat documentaire change. + +## C-15-13a-2 — 15-13a (T4/T8) : l'étape CI retire aussi chaque mot de passe MariaDB SEUL, l'autre posé + +- **Contexte** : l'AC 4 b exige que `docker compose config -q` **sans les deux** variables échoue en nommant l'une d'elles. Jouée localement, la mutation **M27** de la fiche (« remettre `:-kesh_dev_root` ») **survit** à cette forme : `MARIADB_PASSWORD` reste obligatoire, Compose refuse en la nommant, et l'étape passe — le retour du défaut publié de root ne se voit pas en CI. +- **Retenu** : l'étape garde la vérification de l'AC 4 b et ajoute une boucle : pour chaque variable, retirer les deux puis poser **l'autre** ; Compose doit refuser **en nommant celle qui manque** (`required variable is missing`). M27 rougit (rejouée sous `bash -eo pipefail` sur une copie). Reste vert, à dessein, le retour d'un défaut sur le seul `MARIADB_PASSWORD` du service `mariadb` : la `DATABASE_URL` de `kesh-api` exige encore la variable, Compose refuse toujours son absence — aucun changement de comportement ; le test Rust `mariadb` le voit. +- **Écartées** : s'en tenir à l'esquisse de la fiche (M27 survit) ; ne compter que sur le test Rust (l'AC 4 veut le refus exercé par Compose lui-même). +- **Réversible** : oui. + +## C-15-13a-3 — Recette `ALTER USER` : sous-shell `set -e`, mots de passe affichés avant, compte lu dans le conteneur (revue P1, B-L1, E-3 = A-5) + +- **Contexte** : la revue de code P1 relève que la recette n'affichait les nouveaux mots de passe qu'**après** l'`ALTER USER` (session coupée = mots de passe perdus), sans arrêt à la première erreur, et avec le compte `kesh` en dur alors que `MARIADB_USER` est réglable. +- **Retenu** : la recette tient dans un sous-shell `( set -e … )` — arrêt à la première erreur sans fermer le terminal interactif ; `echo` des deux valeurs **avant** l'application, puis `read` (pause pour les recopier) ; compte applicatif lu par `docker compose exec -T mariadb printenv MARIADB_USER` et passé **en premier** (seule ligne sans `IF EXISTS` : s'il est refusé, rien n'a changé) ; ligne témoin `CHANGÉS DANS LA BASE` ; `up -d` dans un **second** bloc, pour qu'un collage du premier ne le lance pas avant l'enregistrement de `.env`. Rejouée sur un projet Compose jetable `kesh1513ap1` (`MARIADB_USER=compta`) : nominal, échec de connexion root (rc 1, pas de ligne témoin), compte absent (rc 1, ERROR 1396, root et compte inchangés). +- **Écartées** : `set -e` nu (fermerait le terminal sur une erreur) ; écrire les mots de passe dans un fichier sous `umask 077` (un fichier de secrets de plus à effacer) ; dire seulement « adaptez `kesh` » (le lecteur ne le fera pas). +- **Réversible** : oui (texte du manuel). + +## C-15-13a-4 — Volume `kesh_db_data` du manuel laissé à #575 (revue P1, B-L4) + +- **Contexte** : B-L4 relève le nom de volume `kesh\_db\_data` (`admin-manual.tex`, procédure de mise à jour standard et stratégie de sauvegarde), faux contre `kesh-mariadb-data`. +- **Retenu** : laissé à **#575**, dont le constat cite précisément ces deux sites ; la story 15-13a ne les touche pas. Le reste de B-L4 (`cd /opt/kesh` de la restauration) est traité : renvoi à `COMPOSE_DIR` du script de sauvegarde. +- **Écartée** : corriger ici (double traitement d'une issue ouverte, et la section Synology de #575 demande une refonte plus large). - **Réversible** : oui. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a172930b5..60c71b87a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -1,3 +1,5 @@ +# last_updated: 2026-10-09 (40) (15-13a intégrée sur dc4bc58b (15-6b, 15-12b) — registre et sprint-status par union, PDF du manuel d'administration régénéré ; gates sur l'état rebasé : backend 3033/3033, Vitest 1139/1139, E2E 244 / 7 KF-029 + 2 KF-045 + sidebar-navigation:75 (KF-052, rejoué vert)) +# last_updated: 2026-10-09 (39) (renuméroté au rebase sur dc4bc58b, (36) sur 200f5e79) (15-13a done — revue de code close : P1 Sonnet ×3 1 MEDIUM distinct → P2 ciblée Haiku 0 ; base 200f5e79, rebase sans objet ; gates : backend 2980/2980, Vitest 1095/1095, E2E 245 / 7 KF-029 + 2 KF-045 du matin) # last_updated: 2026-10-09 (38) (renuméroté au rebase sur f8b2accd, (37) sur 200f5e79) (15-12b done — revue de code P1 close (Sonnet ×3, 0 au-dessus de LOW, 16 LOW) ; rebasée sur 200f5e79 (15-7a2) ; gates sur l'état rebasé : backend 2993/2993, Vitest 1116/1116, E2E 246 / 7 KF-029 + 2 KF-045 + 1 pollution rejouée verte ; choix C-15-12b-4) # last_updated: 2026-10-09 (37) (renuméroté au rebase sur f8b2accd, (36) sur 200f5e79) (15-12b développée, en review — gates backend 2970/2970, Vitest 1115/1115, E2E 245 / 9 attendus ; choix C-15-12b-1 à 3) # last_updated: 2026-10-09 (36) (15-6b done — revue de code P1 close, 0 au-dessus de LOW ; rebasée sur 200f5e79 (15-7a2) ; gates sur l'état rebasé 3006/3006, Vitest 1118/1118, E2E 247 / 7 KF-029 + 2 KF-045 ; choix C-15-6b-3) @@ -374,6 +376,9 @@ development_status: 15-12-cloture-dans-l-ordre: split # 2026-10-09 DÉCOUPÉE à la remédiation de la validation P2 (Sonnet ×2 : R 2 MEDIUM / 5 LOW, F 3 MEDIUM / 8 LOW → 0 C / 0 H / 4 MEDIUM / 10 LOW distincts ; F3 + R3 : la dérogation au découpage ne relevait pas de l'exception codifiée, la dépendance A/B n'était pas à sens unique — décision de l'orchestrateur, C107) en 15-12a (l'ordre, refs #543) + 15-12b (le filet, closes #543). Fiche devenue index ; version complète au commit dae3a618. Remédiation P2 portée dans les deux fiches (C108-C112). Historique : 2026-10-08 — closes #543 (P1). SPÉCIFIÉE (C89), EN VALIDATION. Clôture refusée tant qu'un exercice antérieur est ouvert (409 EARLIER_FISCAL_YEAR_OPEN), création refusée sous un exercice postérieur clos, filet LATER_FISCAL_YEAR_CLOSED généralisé aux deux points de passage du journal (create_in_tx_inner sans verrou, delete_in_tx sans condition — la dévalidation), lot de rapprochement, bandeau d'état hérité et bouton désactivé à l'écran des exercices. 23 AC, T0-T13. Dérogation au découpage (8 modules), coupe de repli 15-12a/15-12b. 2026-10-09 VALIDATION P1 (Opus ×2) : 1 HIGH / 4 MEDIUM / 14 LOW distincts, aucun recyclé, tous remédiés (C100 : preuve du filet refondue, tests de concurrence ordonnés, cycle clôture/contre-passation, réparation dans l'ordre LIFO, inventaire doc par recherche) ; dérogation recomptée (11 modules) et réargumentée, repli élargi au premier défaut recyclé. Prochaine : P2 complète (Sonnet). 15-12a-cloture-dans-l-ordre: done # 2026-10-09 — refs #543 (la 15-12b fermera l'issue). DONE : revue de code close (P1 Sonnet ×3 2 MEDIUM d'origine → P2 ciblée Haiku 0) ; rebasée sur de285ea8 (15-7a1, 15-11b) ; gates sur l'état rebasé : backend 2934/2934, Vitest 1095/1095, E2E 244 / 7 KF-029 + 2 KF-045 du matin + xss-token-protection:82 (montage, rejouée verte). Développement : mutations (i) (ii) (iv) (viii) (ix) (x) (xi) tuées, (iii) observée. (worktree kesh-15-12a, branche story/15-12a-cloture-dans-l-ordre). Validation close (P4 Sonnet ×2 : 0 au-dessus de LOW ; 15 des 16 LOW appliqués en T0, tous sauf R7 — fiche 15-1a, à l'orchestrateur). Invariant I : clôture refusée tant qu'un antérieur est ouvert (409 EARLIER_FISCAL_YEAR_OPEN), création refusée sous un postérieur clos (400 LATER_FISCAL_YEAR_CLOSED, message propre à la création — C-15-12a-1), antérieurs verrouillés un par un par clé primaire puis relecture verrouillante (C119), clôture et création rejouées. Ordre : 15-12a → 15-12b → 15-1a-i → 15-1a-ii. 15-12b-filet-sous-un-bilan-clos: done # 2026-10-09 — closes #543 (P1). DONE : revue de code close (P1 Sonnet ×3 : 0 au-dessus de LOW, 16 LOW traités sans ligne de production ; B-1 = E-1 suivi par #569 ; C-15-12b-4) ; rebasée sur 200f5e79 (15-7a2) ; gates sur l'état rebasé : backend 2993/2993, Vitest 1116/1116, E2E 246 / 7 KF-029 + 2 KF-045 + 1 pollution rejouée verte. DÉVELOPPÉE (worktree kesh-15-12b) : gates backend 2970/2970, Vitest 1115/1115, E2E 245 / 7 KF-029 + 2 KF-045 ; mutations (v) (vi) (vii-a) (vii-b) tuées ; choix C-15-12b-1 à 3. Historique : Créée au découpage de la 15-12 (C107), EN VALIDATION. ⛔ DÉPEND de 15-12a. Filet LATER_FISCAL_YEAR_CLOSED aux deux points de passage du journal (create_in_tx_inner sans verrou, delete_in_tx sans condition — la dévalidation), inventaire fermé des écrivains, lot de rapprochement aux trois voies dont accept_one_rule (C108), bandeau d'état hérité et réparation LIFO, autres écrans, prédicteurs d'annulation en angle mort (C120). 11 AC (numéros d'origine), 9 tâches ; 10 modules (dérogation écrite, C121). Avant la 15-1a de préférence (delete_in_tx partagé, C112 ; précédence C117). P3 (Opus ×2) remédiée le 2026-10-09. Prochaine : P4 ciblée (une lentille) sur le diff de la remédiation P3. + 15-13-mariadb-et-sauvegarde: split # 2026-10-09 DÉCOUPÉE après la validation P3 (signal D5 de recyclage levé deux passes de suite, C-15-13-16) en 15-13a (#551, MariaDB) + 15-13b (#552, #576, sauvegarde). Fiche devenue index ; fiche unique au commit 8a9bcd27. + 15-13a-mariadb-non-publiee-mots-de-passe-obligatoires: done # 2026-10-09 — closes #551 (P2, sécurité), refs #577, refs #578. DONE : revue de code close (P1 Sonnet ×3 1 MEDIUM distinct, E-1 = A-1 → remédiation 7faa16bb sans code Rust de production → P2 ciblée Haiku 0) ; base 200f5e79 inchangée (rebase sans objet) ; gates sur 7e471fd4 : backend 2980/2980, Vitest 1095/1095, E2E 245 / 7 KF-029 + 2 KF-045 du matin, étape CI « Validate compose files » rejouée verte. (worktree kesh-15-13a, branche story/15-13a-mariadb-non-publiee). Développement : 24 mutations rouges ; recette MariaDB rejouée sur conteneurs jetables (T7). Écarts T0 : refus de Compose limité à config/pull/up, variable nommée non déterministe (C-15-13a-1) ; étape CI renforcée, M27 survivait (C-15-13a-2) ; recette ALTER USER refondue (C-15-13a-3), volume laissé à #575 (C-15-13a-4). Validation close (P1 8 MEDIUM → P2 6 → P3 5 → découpage → P4 1 → P5 ciblée 0). + 15-13b-sauvegarde-avant-import-persistante: ready-for-dev # 2026-10-09 — #552, #576. Créée au découpage de la 15-13 (C-15-13-16) ; indépendante de la 15-13a en code, mêmes fichiers (rebase et recompte des gestes du manuel). epic-15-retrospective: optional # --- v0.2 : Epics ajoutés au scoping 2026-06-04 (cf. _bmad-output/planning-artifacts/epic-17.md) --- # Ordre d'exécution v0.2 : E17 (infra) DÉMARRE, puis E11 TVA, E16 Facturation, E12, E13, E14, E15. diff --git a/crates/kesh-api/Cargo.toml b/crates/kesh-api/Cargo.toml index f12e15549..f7527fe97 100644 --- a/crates/kesh-api/Cargo.toml +++ b/crates/kesh-api/Cargo.toml @@ -69,6 +69,13 @@ lettre = { version = "0.11", default-features = false, features = [ "builder", ] } +# Story 15-13a (#551) — lecture du mot de passe de `DATABASE_URL` pour +# l'avertissement « mot de passe publié / gabarit ». `url` rend le mot de passe +# encodé ; `percent-encoding` le décode (`url` ne le réexporte pas). Toutes deux +# déjà au Cargo.lock (dépendances transitives) : aucun paquet neuf. +url = "2" +percent-encoding = "2" + [dev-dependencies] tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" diff --git a/crates/kesh-api/src/config.rs b/crates/kesh-api/src/config.rs index 49110b008..4c1895303 100644 --- a/crates/kesh-api/src/config.rs +++ b/crates/kesh-api/src/config.rs @@ -575,6 +575,12 @@ impl Config { let database_url = env_nonempty("DATABASE_URL") .ok_or_else(|| ConfigError::MissingVar("DATABASE_URL".into()))?; + // Story 15-13a (#551) : un mot de passe publié dans le dépôt, ou un + // gabarit du manuel recopié, protège la base comme aucun mot de passe. + // Avertir, jamais refuser (C-15-13-2) : la pile de dev et les tests + // tournent sur `kesh_dev`. + avertir_mot_de_passe_base(&database_url); + let port = match env_nonempty("KESH_PORT") { Some(val) => match val.parse::() { Ok(0) => { @@ -1385,6 +1391,101 @@ pub fn env_nonempty(name: &str) -> Option { } } +/// Mots de passe MariaDB que Kesh a lui-même **distribués comme défaut** d'une +/// installation (`docker-compose.yml` et `.env.example` jusqu'à la 0.12.1) : +/// publiés dans le dépôt, donc connus de tous. Liste fermée, comparaison +/// exacte et sensible à la casse. Story 15-13a (#551). +/// +/// Angle mort assumé : d'autres littéraux du dépôt (`kesh_root` de la CI, +/// exemples du manuel hors Docker) n'y entrent pas — ils n'ont jamais été le +/// défaut d'une installation. +pub const MOTS_DE_PASSE_PUBLIES: &[&str] = &["kesh_dev", "kesh_dev_root"]; + +/// Avertissement émis quand le mot de passe de `DATABASE_URL` figure dans +/// [`MOTS_DE_PASSE_PUBLIES`]. Ne cite ni le mot de passe ni l'URL. +pub const AVERTISSEMENT_MOT_DE_PASSE_PUBLIE: &str = "Le mot de passe de DATABASE_URL est un mot de passe publié dans le dépôt Kesh (ancienne valeur par défaut) : acceptable pour le développement seulement. Le changer — manuel d'administration, § Passer à la 0.13.0 et § Changer un mot de passe MariaDB."; + +/// Avertissement émis quand le mot de passe de `DATABASE_URL` est un +/// placeholder de gabarit (cf. [`is_template_placeholder`]). Distinct de +/// [`AVERTISSEMENT_MOT_DE_PASSE_PUBLIE`] : le remède n'est pas le même. Ne cite +/// ni le mot de passe ni l'URL. +pub const AVERTISSEMENT_MOT_DE_PASSE_GABARIT: &str = "Le mot de passe de DATABASE_URL est un placeholder de gabarit (par exemple une valeur entre chevrons <…> recopiée du manuel) : il protège la base comme un mot de passe publié. Le changer — manuel d'administration, § Changer un mot de passe MariaDB (ALTER USER d'abord, .env ensuite)."; + +/// Ce que le contrôle du mot de passe de `DATABASE_URL` reconnaît. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MotDePasseBase { + /// Une entrée de [`MOTS_DE_PASSE_PUBLIES`]. + Publie, + /// Un placeholder de gabarit ([`is_template_placeholder`]). + Gabarit, +} + +/// Qualifie le mot de passe de `database_url`. +/// +/// L'URL est analysée par `url` ; [`url::Url::password`] rend la forme +/// **encodée**, décodée ici par `percent_decode_str` puis +/// `decode_utf8_lossy` — pas par `form_urlencoded` (qui lirait `+` comme un +/// espace), pas par `decode_utf8` (dont l'erreur sur `%FF` inviterait à faire +/// échouer le démarrage : une valeur non UTF-8 n'est ni publiée ni gabarit, et +/// sa forme décodée avec `U+FFFD` ne déclenche rien). +/// +/// `None` pour une URL qui ne s'analyse pas (mot de passe contenant `/`, `?` +/// ou `#` non encodés : l'autorité est tronquée), sans mot de passe, ou au mot +/// de passe ni publié ni gabarit. Aucune des listes n'est entre chevrons : un +/// même mot de passe ne peut être les deux. +fn qualifier_mot_de_passe_base(database_url: &str) -> Option { + let url = url::Url::parse(database_url).ok()?; + let encode = url.password()?; + let decode = percent_encoding::percent_decode_str(encode).decode_utf8_lossy(); + if MOTS_DE_PASSE_PUBLIES.contains(&decode.as_ref()) { + Some(MotDePasseBase::Publie) + } else if is_template_placeholder(&decode) { + Some(MotDePasseBase::Gabarit) + } else { + None + } +} + +/// Émet au plus **un** `tracing::warn!` selon [`qualifier_mot_de_passe_base`]. +/// N'empêche jamais le démarrage (AC 5 b de la Story 15-13a). +fn avertir_mot_de_passe_base(database_url: &str) { + match qualifier_mot_de_passe_base(database_url) { + Some(MotDePasseBase::Publie) => tracing::warn!("{}", AVERTISSEMENT_MOT_DE_PASSE_PUBLIE), + Some(MotDePasseBase::Gabarit) => { + tracing::warn!("{}", AVERTISSEMENT_MOT_DE_PASSE_GABARIT) + } + None => {} + } +} + +/// Numéro d'erreur MariaDB **1045** : *Access denied for user … (using +/// password: …)* — l'identifiant est refusé. +pub const MARIADB_ACCES_REFUSE: u16 = 1045; + +/// Indice ajouté au message d'échec de connexion initiale quand MariaDB refuse +/// l'identifiant (1045). Ne cite ni `DATABASE_URL` ni son mot de passe. +pub const INDICE_ACCES_REFUSE: &str = "Indice : MariaDB refuse l'identifiant de DATABASE_URL. Avec docker-compose.yml : MARIADB_PASSWORD n'est lu qu'à la création de la base — changer sa valeur dans .env ne change pas le mot de passe enregistré (manuel d'administration, Passer à la 0.13.0)."; + +/// Extrait le numéro d'erreur MariaDB d'une erreur `sqlx`, s'il y en a un +/// (`Error::Database` d'origine MySQL/MariaDB). Story 15-13a (#551). +/// +/// Séparée de [`indice_connexion`] parce que `MySqlDatabaseError` n'a pas de +/// constructeur public : seul un binaire réel (test `demarrage_mariadb`) peut +/// exercer l'extraction ; la décision, pure, se teste seule. +pub fn numero_erreur_mariadb(erreur: &sqlx::Error) -> Option { + erreur + .as_database_error()? + .try_downcast_ref::() + .map(|e| e.number()) +} + +/// Indice à joindre au message d'échec de connexion, selon le numéro d'erreur +/// MariaDB : [`INDICE_ACCES_REFUSE`] pour 1045, rien sinon (1044 — droits sur +/// la base —, 1049 — base absente —, erreur réseau). Pure. +pub fn indice_connexion(numero: Option) -> Option<&'static str> { + (numero == Some(MARIADB_ACCES_REFUSE)).then_some(INDICE_ACCES_REFUSE) +} + /// Sous-chaînes (en minuscules) qui signalent un placeholder du gabarit /// `.env.example` resté tel quel — comparées au texte passé par /// `to_ascii_lowercase`. Story 15-11a (#557). @@ -1401,7 +1502,9 @@ const TEMPLATE_PLACEHOLDERS: &[&str] = &["generate_me"]; /// Le trim est local au contrôle : il ne présume pas de la lecture (depuis la /// Story 15-11b, `env_nonempty` trime déjà les deux secrets), et `" "` doit /// être refusé quand même. Sert aux contrôles de -/// `KESH_JWT_SECRET` et de `KESH_ADMIN_PASSWORD` dans [`Config::from_env`]. +/// `KESH_JWT_SECRET` et de `KESH_ADMIN_PASSWORD` dans [`Config::from_env`], +/// et à l'avertissement sur le mot de passe de `DATABASE_URL` +/// ([`qualifier_mot_de_passe_base`], Story 15-13a). fn is_template_placeholder(value: &str) -> bool { let lower = value.to_ascii_lowercase(); if TEMPLATE_PLACEHOLDERS.iter().any(|p| lower.contains(p)) { @@ -3434,3 +3537,94 @@ mod smtp_config_tests { assert_eq!(config.smtp_host.as_deref(), Some("fd00::25")); } } + +/// Story 15-13a (#551) — avertissement sur le mot de passe de `DATABASE_URL` +/// et indice du refus 1045. +#[cfg(test)] +mod mot_de_passe_base_tests { + use super::tests::{env_lock, from_env_with}; + use super::*; + + /// Compte les deux avertissements dans `logs`. + fn compte(logs: &str) -> (usize, usize) { + ( + logs.matches(AVERTISSEMENT_MOT_DE_PASSE_PUBLIE).count(), + logs.matches(AVERTISSEMENT_MOT_DE_PASSE_GABARIT).count(), + ) + } + + /// Test 7 de la fiche — AC 5 a, a-bis, b. Chaque cas averti l'est **une** + /// fois, par le bon message ; les témoins ne déclenchent rien ; aucun + /// message capturé ne contient le mot de passe (formes encodée et décodée) + /// ni l'URL ; `from_env` réussit toujours. + #[test] + fn from_env_warns_on_published_database_password() { + // (URL, mot de passe encodé, mot de passe décodé) + let publies = [ + ("mysql://kesh:kesh_dev@h/kesh", "kesh_dev", "kesh_dev"), + ( + "mysql://kesh:kesh_dev_root@h/kesh", + "kesh_dev_root", + "kesh_dev_root", + ), + ("mysql://kesh:kesh%5Fdev@h/kesh", "kesh%5Fdev", "kesh_dev"), + ]; + let gabarits = [ + ( + "mysql://kesh:%3Cmot%20de%20passe%20utilisateur%20applicatif%3E@h/kesh", + "%3Cmot%20de%20passe%20utilisateur%20applicatif%3E", + "", + ), + // Forme non encodée, telle que Compose la compose depuis `.env` : + // `url` encode l'espace et les chevrons de l'identifiant. + ( + "mysql://kesh:@h/kesh", + "%3Cmot%20de%20passe%20utilisateur%20applicatif%3E", + "", + ), + ( + "mysql://kesh:%3CMARIADB_PASSWORD%3E@h/kesh", + "%3CMARIADB_PASSWORD%3E", + "", + ), + ]; + let temoins = [ + "mysql://kesh_dev:fort-7f3a9c2e@h/kesh", // l'utilisateur, pas le mot de passe + "mysql://kesh:Kesh_Dev@h/kesh", // casse + "mysql://kesh:a%3Cb%3Ec@h/kesh", // chevrons intérieurs + "mysql://kesh:%FF@h/kesh", // octet non UTF-8 + "mysql://kesh:9f2c4e7a1b3d5f60@h/kesh", // mot de passe fort + "mysql://kesh@h/kesh", // sans mot de passe + ]; + + let _guard = env_lock(); + for (attendu, cas) in [((1, 0), &publies[..]), ((0, 1), &gabarits[..])] { + for (url, encode, decode) in cas { + let (result, logs) = from_env_with(&[("DATABASE_URL", url)]); + assert!(result.is_ok(), "{url} : Kesh démarre quand même"); + assert_eq!(compte(&logs), attendu, "{url} : logs = {logs}"); + for interdit in [*url, *encode, *decode] { + assert!( + !logs.contains(interdit), + "{url} : le journal cite « {interdit} » : {logs}" + ); + } + } + } + for url in temoins { + let (result, logs) = from_env_with(&[("DATABASE_URL", url)]); + assert!(result.is_ok(), "{url} : Kesh démarre"); + assert_eq!(compte(&logs), (0, 0), "{url} : logs = {logs}"); + } + } + + /// Test 9 de la fiche — AC 6 b : la décision pure. + #[test] + fn indice_connexion_refusee() { + assert_eq!(indice_connexion(Some(1045)), Some(INDICE_ACCES_REFUSE)); + for autre in [Some(1049), Some(1044), None] { + assert_eq!(indice_connexion(autre), None, "{autre:?}"); + } + assert!(!INDICE_ACCES_REFUSE.contains("mysql://")); + } +} diff --git a/crates/kesh-api/src/main.rs b/crates/kesh-api/src/main.rs index 6fdb14d0e..8fbecbe22 100644 --- a/crates/kesh-api/src/main.rs +++ b/crates/kesh-api/src/main.rs @@ -80,9 +80,18 @@ async fn main() { pool } Err(e) => { + // Story 15-13a (#551) : un 1045 (identifiant refusé) reçoit un + // indice — le piège le plus probable après la 0.13.0 est un mot de + // passe neuf écrit dans `.env` alors que MariaDB garde celui de la + // création. Ni le message ni l'indice ne citent l'URL. + let indice = + kesh_api::config::indice_connexion(kesh_api::config::numero_erreur_mariadb(&e)) + .map(|i| format!(" — {i}")) + .unwrap_or_default(); tracing::error!( - "Base de données indisponible au démarrage — l'authentification ne peut pas fonctionner sans DB. Arrêt. Erreur : {}", - e + "Base de données indisponible au démarrage — l'authentification ne peut pas fonctionner sans DB. Arrêt. Erreur : {}{}", + e, + indice ); std::process::exit(1); } diff --git a/crates/kesh-api/tests/common/binaire.rs b/crates/kesh-api/tests/common/binaire.rs new file mode 100644 index 000000000..14f61a87f --- /dev/null +++ b/crates/kesh-api/tests/common/binaire.rs @@ -0,0 +1,34 @@ +//! Lancement du binaire `kesh-api` dans un environnement maîtrisé — Story +//! 15-13a (factorisé de `configuration_transmise.rs`). +//! +//! Inclus par `#[path = "common/binaire.rs"] mod binaire;` — **pas** par +//! `mod common;`, qui tirerait l'amorce de base de `common/mod.rs` dans des +//! binaires de test qui ne l'emploient pas. +//! +//! Le helper ne fait **aucune** assertion : chaque appelant pose la sienne +//! (code de sortie, contenu de la sortie). + +/// Lance `kesh-api` dans un répertoire temporaire (aucun `.env` à charger), +/// avec un environnement **vidé** (`env_clear`), `NO_COLOR=1`, puis les +/// couples `env` dans l'ordre donné. Attend la fin du processus. +pub fn lancer_binaire(env: &[(&str, &str)]) -> std::process::Output { + let dir = tempfile::tempdir().expect("répertoire temporaire"); + let mut commande = std::process::Command::new(env!("CARGO_BIN_EXE_kesh-api")); + commande + .current_dir(dir.path()) + .env_clear() + .env("NO_COLOR", "1"); + for (cle, valeur) in env { + commande.env(cle, valeur); + } + commande.output().expect("lancement du binaire kesh-api") +} + +/// Sortie complète d'un lancement (stdout puis stderr), en texte. +pub fn texte(sortie: &std::process::Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&sortie.stdout), + String::from_utf8_lossy(&sortie.stderr) + ) +} diff --git a/crates/kesh-api/tests/configuration_transmise.rs b/crates/kesh-api/tests/configuration_transmise.rs index fa6f693a3..b49c23807 100644 --- a/crates/kesh-api/tests/configuration_transmise.rs +++ b/crates/kesh-api/tests/configuration_transmise.rs @@ -30,6 +30,13 @@ //! catalogues i18n, du manuel français **et des littéraux de chaîne du code //! de production** (doc-comments, macros, attributs compris) nomme une //! variable connue. +//! - **(M) Service MariaDB** (15-13a, #551) — le service `mariadb` de +//! `docker-compose.yml` ne publie aucun port (pas de clé `ports:`) et ses +//! deux mots de passe sont de la forme `${NOM:?…}` (obligatoires, vide +//! refusé) ; la `DATABASE_URL` composée de `kesh-api` exige +//! `${MARIADB_PASSWORD:?` (famille (V), [`VALEURS_COMPOSEES`]) ; aucun +//! fichier distribué ne porte `kesh_dev`, et les mots de passe MariaDB de +//! `.env.example` sont des lignes commentées. //! - **(S) Auto-test** des extracteurs sur des sources synthétiques. //! //! # Ce qu'elle n'établit PAS (angles morts écrits) @@ -49,7 +56,10 @@ //! (`rust_log_vide_vaut_info`). //! `TMPDIR` (`std::env::temp_dir()`) est inventorié mais non compté dans //! l'ensemble lu. `docker-compose.dev.yml` (pile de développement, non -//! distribuée) n'est pas contraint. +//! distribuée) n'est pas contraint — ni par (T), ni par (M) : ses mots de +//! passe de développement et son port en loopback y restent. (M) ne voit pas +//! un mot de passe **root** posé dans `.env` sur une valeur gabarit `<…>` +//! (issue #578). //! //! L'analyseur YAML (`yaml-rust2`) ne connaît pas les clés de fusion //! (`<<: *ancre`) : une forme non reconnue fait **rougir**, jamais passer. La @@ -61,6 +71,9 @@ use std::path::{Path, PathBuf}; use yaml_rust2::{Yaml, YamlLoader}; +#[path = "common/binaire.rs"] +mod binaire; + // --------------------------------------------------------------------------- // Listes fermées // --------------------------------------------------------------------------- @@ -168,8 +181,10 @@ const AJOUTS: &[(&str, Compose)] = &[ /// valeur contient l'interpolation annoncée. const VALEURS_COMPOSEES: &[(&str, Compose, &str)] = &[ // `docker-compose.yml` compose l'URL depuis les `MARIADB_*` de son - // service MariaDB : la ligne `DATABASE_URL` de `.env` y est ignorée. - ("DATABASE_URL", Compose::Y, "${MARIADB_"), + // service MariaDB : la ligne `DATABASE_URL` de `.env` y est ignorée. Le + // mot de passe y est OBLIGATOIRE et non vide (Story 15-13a, #551) : aucun + // défaut publié ne doit pouvoir s'y glisser. + ("DATABASE_URL", Compose::Y, "${MARIADB_PASSWORD:?"), ]; /// Variables de `.env.example` que le code ne lit pas, utilisées par les @@ -258,9 +273,12 @@ enum ValeurEnv { NonReconnue(String), } -/// Le service `kesh-api` d'un compose, tel que ce test le lit. +/// Un service d'un compose (`kesh-api`, ou `mariadb` depuis la Story 15-13a), +/// tel que ce test le lit. #[derive(Debug, Default)] struct Service { + /// Clé `ports:` présente (quelle qu'en soit la forme). Story 15-13a. + ports: bool, /// Entrées de `environment:`, dans l'ordre. environnement: Vec<(String, ValeurEnv)>, /// `env_file` présent. @@ -293,23 +311,25 @@ fn scalaire(y: &Yaml) -> Option { } } -/// Analyse un compose et rend son service `kesh-api`, ou les raisons pour +/// Analyse un compose et rend son service `nom`, ou les raisons pour /// lesquelles il ne se lit pas (forme non reconnue : rouge, jamais ignorée). -fn service_kesh_api(source: &str) -> Result> { +/// Généralisée de `service_kesh_api` à la Story 15-13a (service `mariadb`). +fn service(source: &str, nom: &str) -> Result> { let docs = YamlLoader::load_from_str(source).map_err(|e| vec![format!("YAML invalide : {e}")])?; let doc = docs .first() .ok_or_else(|| vec!["document YAML vide".to_string()])?; - let svc = &doc["services"]["kesh-api"]; + let svc = &doc["services"][nom]; let Some(hash) = svc.as_hash() else { - return Err(vec!["service `kesh-api` introuvable".to_string()]); + return Err(vec![format!("service `{nom}` introuvable")]); }; let mut erreurs = Vec::new(); let mut out = Service::default(); for (k, v) in hash { match k.as_str() { Some("env_file") => out.env_file = true, + Some("ports") => out.ports = true, Some("image") => out.image = scalaire(v), Some("volumes") => match v { Yaml::Array(a) => out.volumes = a.clone(), @@ -538,8 +558,11 @@ enum Interpolation<'a> { DefautSiVide(&'a str, &'a str), /// `${NOM-défaut}` DefautSiAbsente(&'a str, &'a str), - /// `${NOM:?message}` ou `${NOM?message}` - Obligatoire(&'a str), + /// `${NOM:?message}` : refuse une variable absente **ou vide**. + ObligatoireNonVide(&'a str), + /// `${NOM?message}` : ne refuse qu'une variable **absente** — une ligne + /// `NOM=` vide passe. Scindée de la précédente à la Story 15-13a. + ObligatoireSiAbsente(&'a str), } fn interpolation(valeur: &str) -> Option> { @@ -560,8 +583,10 @@ fn interpolation(valeur: &str) -> Option> { Some(Interpolation::DefautSiVide(nom, d)) } else if let Some(d) = reste.strip_prefix('-') { Some(Interpolation::DefautSiAbsente(nom, d)) - } else if reste.starts_with(":?") || reste.starts_with('?') { - Some(Interpolation::Obligatoire(nom)) + } else if reste.starts_with(":?") { + Some(Interpolation::ObligatoireNonVide(nom)) + } else if reste.starts_with('?') { + Some(Interpolation::ObligatoireSiAbsente(nom)) } else { None } @@ -623,7 +648,8 @@ fn controle_valeur(compose: Compose, cle: &str, valeur: &ValeurEnv) -> Option n, + | Interpolation::ObligatoireNonVide(n) + | Interpolation::ObligatoireSiAbsente(n) => n, }; if nom != cle { return Some(format!( @@ -662,6 +688,93 @@ fn controle_valeur(compose: Compose, cle: &str, valeur: &ValeurEnv) -> Option Vec { + let svc = match service(source, "mariadb") { + Ok(s) => s, + Err(e) => return e, + }; + let mut erreurs = Vec::new(); + if svc.ports { + erreurs.push( + "service `mariadb` : clé `ports:` présente — le port de la base ne doit pas être publié \ + (Kesh passe par le réseau interne ; un port publié contourne UFW)" + .to_string(), + ); + } + for cle in MOTS_DE_PASSE_MARIADB { + let ok = match svc.valeur(cle) { + Some(ValeurEnv::Scalaire(v)) => { + matches!(interpolation(v), Some(Interpolation::ObligatoireNonVide(n)) if n == *cle) + } + _ => false, + }; + if !ok { + erreurs.push(format!( + "service `mariadb` : `{cle}` doit être de la forme `${{{cle}:?message}}` (obligatoire, \ + non vide, sans défaut), trouvé {:?}", + svc.valeur(cle) + )); + } + } + erreurs +} + +/// (M) Aucun fichier distribué ne porte [`MOT_DE_PASSE_PUBLIE`] (recherche de +/// sous-chaîne, commentaires compris) ; dans `.env.example`, toute ligne +/// d'affectation d'un mot de passe MariaDB est commentée. `fichiers` : +/// (nom, contenu). +fn controle_mots_de_passe_publies(fichiers: &[(&str, &str)]) -> Vec { + let mut erreurs = Vec::new(); + for (nom, contenu) in fichiers { + for (i, ligne) in contenu.lines().enumerate() { + if ligne.contains(MOT_DE_PASSE_PUBLIE) { + erreurs.push(format!( + "{nom}:{} : mot de passe publié `{MOT_DE_PASSE_PUBLIE}`", + i + 1 + )); + } + } + if *nom == ".env.example" { + for (i, var, commentee) in lignes_affectation(contenu) { + if MOTS_DE_PASSE_MARIADB.contains(&var.as_str()) && !commentee { + erreurs.push(format!( + ".env.example:{} : `{var}` doit rester commentée et sans valeur (un `.env` copié \ + du gabarit fait alors refuser Compose)", + i + 1 + )); + } + } + } + } + erreurs +} + /// Lignes d'affectation de `.env.example` : `^#?\s*[A-Z][A-Z0-9_]*=`. Rend /// (index de ligne, nom, commentée). fn lignes_affectation(source: &str) -> Vec<(usize, String, bool)> { @@ -1685,9 +1798,9 @@ fn corpus_code() -> Vec<(String, String)> { fn services() -> (Service, Service, String) { let y_source = lire(Compose::Y.fichier()); let p_source = lire(Compose::P.fichier()); - let y = service_kesh_api(&y_source).unwrap_or_else(|e| panic!("docker-compose.yml : {e:?}")); - let p = - service_kesh_api(&p_source).unwrap_or_else(|e| panic!("docker-compose.prod.yml : {e:?}")); + let y = service(&y_source, "kesh-api").unwrap_or_else(|e| panic!("docker-compose.yml : {e:?}")); + let p = service(&p_source, "kesh-api") + .unwrap_or_else(|e| panic!("docker-compose.prod.yml : {e:?}")); (y, p, y_source) } @@ -1756,6 +1869,42 @@ fn env_example() { ); } +/// Test 1 de la fiche 15-13a — (M) le service `mariadb` de +/// `docker-compose.yml`. +#[test] +fn mariadb() { + echouer_si( + controle_service_mariadb(&lire(Compose::Y.fichier())), + "(M) service mariadb", + ); +} + +/// Test 2 de la fiche 15-13a — (M) aucun mot de passe publié dans les +/// fichiers distribués ; mots de passe MariaDB commentés dans le gabarit. +#[test] +fn mots_de_passe_publies() { + let contenus: Vec<(&str, String)> = FICHIERS_DISTRIBUES.iter().map(|f| (*f, lire(f))).collect(); + let fichiers: Vec<(&str, &str)> = contenus.iter().map(|(n, c)| (*n, c.as_str())).collect(); + // Assertion de montage : le gabarit porte bien les deux lignes commentées. + let gabarit = &contenus + .iter() + .find(|(n, _)| *n == ".env.example") + .expect("montage : .env.example doit figurer dans FICHIERS_DISTRIBUES") + .1; + for cle in MOTS_DE_PASSE_MARIADB { + assert!( + lignes_affectation(gabarit) + .iter() + .any(|(_, v, c)| v == cle && *c), + "montage : `#{cle}=` attendue dans .env.example" + ); + } + echouer_si( + controle_mots_de_passe_publies(&fichiers), + "(M) mots de passe publiés", + ); +} + #[test] fn fantomes() { let mut corpus = corpus_texte(); @@ -1777,7 +1926,7 @@ fn lectures() { fn svc(env_yaml: &str) -> Service { let source = format!("services:\n kesh-api:\n environment:\n{env_yaml}"); - service_kesh_api(&source).expect("YAML synthétique valide") + service(&source, "kesh-api").expect("YAML synthétique valide") } fn valeur_de(env_yaml: &str, cle: &str, compose: Compose) -> Option { @@ -1793,6 +1942,63 @@ fn s_liste_et_dictionnaire_rendent_les_memes_cles() { assert_eq!(d.valeur("KESH_PORT"), l.valeur("KESH_PORT")); } +/// Test 4 de la fiche 15-13a — (S) le contrôle (M) du service `mariadb` sur +/// des sources synthétiques. +#[test] +fn s_service_mariadb() { + let source = |root: &str, mdp: &str, ports: &str| { + format!( + "services:\n mariadb:\n environment:\n MARIADB_ROOT_PASSWORD: {root}\n \ + MARIADB_PASSWORD: {mdp}\n{ports}" + ) + }; + let ok_root = "${MARIADB_ROOT_PASSWORD:?m}"; + let ok_mdp = "${MARIADB_PASSWORD:?m}"; + // Acceptées : forme `:?` nue, et citée (les guillemets tombent à l'analyse). + assert_eq!( + controle_service_mariadb(&source(ok_root, ok_mdp, "")), + Vec::::new() + ); + assert_eq!( + controle_service_mariadb(&source(&format!("\"{ok_root}\""), ok_mdp, "")), + Vec::::new() + ); + // Refusées. + for (cas, src) in [ + ( + "ports", + source( + ok_root, + ok_mdp, + " ports:\n - \"127.0.0.1:3306:3306\"\n", + ), + ), + ("défaut", source(ok_root, "${MARIADB_PASSWORD:-x}", "")), + ("simple", source(ok_root, "${MARIADB_PASSWORD}", "")), + ("forme ?", source(ok_root, "${MARIADB_PASSWORD?m}", "")), + ( + "autre variable", + source(ok_root, "${MARIADB_ROOT_PASSWORD:?m}", ""), + ), + ("littéral", source(ok_root, "kesh_dev", "")), + ] { + assert_eq!( + controle_service_mariadb(&src).len(), + 1, + "{cas} doit rougir une fois : {src}" + ); + } + // Mots de passe publiés : commentaire compris, ligne active refusée. + let rouge = + controle_mots_de_passe_publies(&[("docker-compose.yml", "# ancien : kesh_dev_root\n")]); + assert_eq!(rouge.len(), 1, "{rouge:?}"); + let rouge = controle_mots_de_passe_publies(&[( + ".env.example", + "MARIADB_ROOT_PASSWORD=x\n#MARIADB_PASSWORD=\n", + )]); + assert_eq!(rouge.len(), 1, "{rouge:?}"); +} + #[test] fn s_cle_sans_valeur_rougit_en_dictionnaire_et_en_liste() { assert!(valeur_de(" KESH_SMTP_PORT:\n", "KESH_SMTP_PORT", Compose::P).is_some()); @@ -1977,7 +2183,7 @@ fn s_sources_de_montage() { ); assert_eq!(source_montage("./a:/data/inboxe", "/data/inbox"), None); let longue = "services:\n kesh-api:\n volumes:\n - type: bind\n source: ./a\n target: /data/inbox\n"; - let s = service_kesh_api(longue).expect("YAML valide"); + let s = service(longue, "kesh-api").expect("YAML valide"); assert!( sources_montages(&s).is_err(), "une forme longue doit rougir" @@ -2230,26 +2436,17 @@ fn s_fantomes_du_code() { /// invalide fait rejouer un avertissement (`warn`) par `main` après /// l'installation de l'abonné. Rend la sortie complète (stdout + stderr). fn sortie_du_binaire_sans_configuration(rust_log: &str) -> String { - let dir = tempfile::tempdir().expect("répertoire temporaire"); - let sortie = std::process::Command::new(env!("CARGO_BIN_EXE_kesh-api")) - .current_dir(dir.path()) - .env_clear() - .env("RUST_LOG", rust_log) - .env("NO_COLOR", "1") - .env("DATABASE_URL", "") - .env("KESH_JWT_SECRET", "") - .env("KESH_LOG_FILE_ROTATION", "inconnue") - .output() - .expect("lancement du binaire kesh-api"); + let sortie = binaire::lancer_binaire(&[ + ("RUST_LOG", rust_log), + ("DATABASE_URL", ""), + ("KESH_JWT_SECRET", ""), + ("KESH_LOG_FILE_ROTATION", "inconnue"), + ]); assert!( !sortie.status.success(), "le binaire doit refuser de démarrer sans configuration" ); - format!( - "{}{}", - String::from_utf8_lossy(&sortie.stdout), - String::from_utf8_lossy(&sortie.stderr) - ) + binaire::texte(&sortie) } /// `RUST_LOG=""` vaut une absence : le niveau `info` s'applique, et diff --git a/crates/kesh-api/tests/demarrage_mariadb.rs b/crates/kesh-api/tests/demarrage_mariadb.rs new file mode 100644 index 000000000..d1359898b --- /dev/null +++ b/crates/kesh-api/tests/demarrage_mariadb.rs @@ -0,0 +1,84 @@ +//! Démarrage face à MariaDB — Story 15-13a (#551). +//! +//! Lance le binaire réel : c'est le seul moyen d'établir (1) que +//! l'avertissement sur un mot de passe publié est bien émis par le chemin de +//! démarrage (`Config::from_env` appelé par `main`), et (2) que l'extraction +//! du numéro d'erreur MariaDB (`numero_erreur_mariadb`) fonctionne sur une +//! vraie `sqlx::Error` — `MySqlDatabaseError` n'a pas de constructeur public. + +#[path = "common/binaire.rs"] +mod binaire; + +use kesh_api::config::{AVERTISSEMENT_MOT_DE_PASSE_PUBLIE, INDICE_ACCES_REFUSE}; + +/// Secret JWT valide (≥ 32 octets, ni `change-me` ni gabarit) : sans lui, +/// `Config::from_env` refuse AVANT toute connexion. +const SECRET: &str = "0123456789abcdef0123456789abcdef-15-13a"; + +/// Test 8 de la fiche — AC 5 (câblage). Hôte `.invalid` : échec de résolution +/// immédiat (mesuré au T0 : 16 ms), non rejoué par `sqlx`. +#[test] +fn mot_de_passe_publie_avertit_au_demarrage() { + let sortie = binaire::lancer_binaire(&[ + ( + "DATABASE_URL", + "mysql://kesh:kesh_dev@kesh-15-13.invalid/kesh", + ), + ("KESH_JWT_SECRET", SECRET), + ("KESH_HOST", "127.0.0.1"), + ]); + let texte = binaire::texte(&sortie); + assert!( + !sortie.status.success(), + "échec de connexion attendu : {texte}" + ); + // Assertion de montage : le binaire est allé jusqu'à la connexion. + assert!( + texte.contains("Base de données indisponible"), + "la connexion doit avoir été tentée : {texte}" + ); + assert!( + texte.contains(AVERTISSEMENT_MOT_DE_PASSE_PUBLIE), + "avertissement « mot de passe publié » attendu : {texte}" + ); +} + +/// Test 10 de la fiche — AC 6 a-b. La `DATABASE_URL` des tests (obligatoire) +/// avec un mauvais mot de passe : MariaDB répond 1045, le message porte +/// l'erreur d'origine et l'indice, sans citer le mot de passe. +#[test] +fn mauvais_mot_de_passe_donne_l_indice() { + let base = std::env::var("DATABASE_URL") + .expect("DATABASE_URL obligatoire pour ce test (MariaDB de test) — pas de saut silencieux"); + let mut url = url::Url::parse(&base).expect("DATABASE_URL des tests lisible"); + let mauvais = "mauvais-15-13"; + url.set_password(Some(mauvais)) + .expect("mot de passe posable"); + let sortie = binaire::lancer_binaire(&[ + ("DATABASE_URL", url.as_str()), + ("KESH_JWT_SECRET", SECRET), + ("KESH_HOST", "127.0.0.1"), + ]); + let texte = binaire::texte(&sortie); + assert!( + !sortie.status.success(), + "échec de connexion attendu : {texte}" + ); + // Assertion de montage : la configuration a été acceptée. + assert!( + !texte.contains("Erreur de configuration"), + "le binaire doit avoir tenté la connexion : {texte}" + ); + assert!( + texte.contains("1045"), + "erreur d'origine (1045) attendue : {texte}" + ); + assert!( + texte.contains(INDICE_ACCES_REFUSE), + "indice du refus 1045 attendu : {texte}" + ); + assert!( + !texte.contains(mauvais), + "la sortie ne doit pas citer le mot de passe : {texte}" + ); +} diff --git a/docker-compose.yml b/docker-compose.yml index 40a24dc66..9039b66e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,22 @@ services: container_name: kesh-mariadb restart: unless-stopped environment: - MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-kesh_dev_root} + # Mots de passe OBLIGATOIRES (`:?`) : sans eux dans `.env`, Compose refuse + # de démarrer en nommant la variable. MariaDB ne les lit qu'à la CRÉATION + # de la base : les changer ensuite dans `.env` ne change pas le mot de + # passe enregistré (manuel d'administration, § Passer à la 0.13.0). + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:?à poser dans .env. Installation existante, reprendre la valeur avec laquelle la base a été créée, jamais une neuve (manuel d'administration, Passer à la 0.13.0)} MARIADB_DATABASE: ${MARIADB_DATABASE:-kesh} MARIADB_USER: ${MARIADB_USER:-kesh} - MARIADB_PASSWORD: ${MARIADB_PASSWORD:-kesh_dev} - ports: - - "3306:3306" + MARIADB_PASSWORD: ${MARIADB_PASSWORD:?à poser dans .env. Installation existante, reprendre la valeur avec laquelle la base a été créée, jamais une neuve (manuel d'administration, Passer à la 0.13.0)} + # Port 3306 NON publié : Kesh atteint MariaDB par le réseau interne + # `kesh-network` (hôte `mariadb`). Un port publié par Docker contourne le + # pare-feu de l'hôte (UFW) et ouvrirait la base à tout le réseau. + # Accès à la base : `docker compose exec mariadb mariadb -u kesh -p`. + # Dépannage ponctuel SEULEMENT — décommenter, lié au loopback, jamais sans + # adresse, puis recommenter : + # ports: + # - "127.0.0.1:3306:3306" volumes: - kesh-mariadb-data:/var/lib/mysql networks: @@ -50,7 +60,7 @@ services: # ne relit pas `.env`). # # Base de données — correspond au service mariadb ci-dessus - DATABASE_URL: mysql://${MARIADB_USER:-kesh}:${MARIADB_PASSWORD:-kesh_dev}@mariadb:3306/${MARIADB_DATABASE:-kesh} + DATABASE_URL: mysql://${MARIADB_USER:-kesh}:${MARIADB_PASSWORD:?à poser dans .env. Installation existante, reprendre la valeur avec laquelle la base a été créée, jamais une neuve (manuel d'administration, Passer à la 0.13.0)}@mariadb:3306/${MARIADB_DATABASE:-kesh} # Application KESH_PORT: ${KESH_PORT:-80} diff --git a/docs/ci.md b/docs/ci.md index 8e604dfb3..2591689fb 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -6,10 +6,12 @@ Documentation de la pipeline GitHub Actions définie dans `.github/workflows/ci. La pipeline `ci.yml` se déclenche sur : -- **Pull request** vers `main` → 4 jobs (`backend`, `frontend`, `e2e`, `docker-build`) -- **Push** sur `main` (= merge de PR) → mêmes 4 jobs +- **Pull request** vers `main` → 3 jobs (`backend`, `frontend`, `docker-build`) +- **Push** sur `main` (= merge de PR) → mêmes 3 jobs -Aucune image Docker n'est publiée sur push `main` — la publication n'a lieu **que** sur push de tag `v*.*.*` (via `release.yml`). Le job `docker-build` du `ci.yml` valide les deux compose distribués (`docker compose config -q` sur `docker-compose.yml` et `docker-compose.prod.yml` — clé inconnue ou en double, interpolation mal formée) puis build l'image en mode sanity (sans push) pour valider que le `Dockerfile` compile. +⚠️ Les sections de ce document qui décrivent un job `e2e` (schéma, tableau des jobs, timeouts, migrations, mode test) ne correspondent à **aucun** job de `ci.yml` : il n'en a que trois. Défaut antérieur, suivi par l'issue #577 (qui vise aussi le smoke E2E annoncé par le `CLAUDE.md`). + +Aucune image Docker n'est publiée sur push `main` — la publication n'a lieu **que** sur push de tag `v*.*.*` (via `release.yml`). Le job `docker-build` du `ci.yml` valide les deux compose distribués (`docker compose config -q` sur `docker-compose.yml` et `docker-compose.prod.yml` — clé inconnue ou en double, interpolation mal formée) — `docker-compose.yml` dans les **deux sens** : il doit se lire avec des mots de passe MariaDB factices, et **refuser** sans eux en nommant `MARIADB_ROOT_PASSWORD` ou `MARIADB_PASSWORD` (Story 15-13a, #551) — puis build l'image en mode sanity (sans push) pour valider que le `Dockerfile` compile. La pipeline `release.yml` se déclenche **uniquement** sur push de tag `v*.*.*` et publie une image Docker Hub SemVer + une GitHub Release. @@ -114,7 +116,7 @@ La publication d'images Docker se fait **uniquement** sur push de tag SemVer `v* | `:{version}` (ex `:0.1.0`) | push tag `v*.*.*` | `release.yml/docker` | Immuable — release SemVer | | `:latest` | push tag `v*.*.*` | `release.yml/docker` | Mutable — toujours la dernière release SemVer | -Le job `docker-build` du `ci.yml` valide d'abord les deux compose distribués (`docker compose config -q`), puis build l'image en mode sanity (sans push) sur chaque PR/push afin de détecter au plus tôt toute régression du `Dockerfile`. L'image est jetée à la fin du runner. La **liste** des variables transmises par les compose est gardée par le test Rust `configuration_transmise` (job `backend`, Story 15-11a). +Le job `docker-build` du `ci.yml` valide d'abord les deux compose distribués (`docker compose config -q` ; pour `docker-compose.yml`, avec des mots de passe MariaDB factices, puis l'échec **exigé** sans eux), puis build l'image en mode sanity (sans push) sur chaque PR/push afin de détecter au plus tôt toute régression du `Dockerfile`. L'image est jetée à la fin du runner. La **liste** des variables transmises par les compose est gardée par le test Rust `configuration_transmise` (job `backend`, Story 15-11a). ### Rotation des secrets Docker Hub diff --git a/docs/manual/fr/admin-manual.pdf b/docs/manual/fr/admin-manual.pdf index e098386b1..0b41a8e11 100644 Binary files a/docs/manual/fr/admin-manual.pdf and b/docs/manual/fr/admin-manual.pdf differ diff --git a/docs/manual/fr/admin-manual.tex b/docs/manual/fr/admin-manual.tex index c4e020520..aea627c34 100644 --- a/docs/manual/fr/admin-manual.tex +++ b/docs/manual/fr/admin-manual.tex @@ -168,12 +168,14 @@ \subsection{Ports réseau} 443 & HTTPS & Accès utilisateur via reverse proxy & Publique (Internet) \\ 80 & HTTP & API Axum backend (interne) & Localhost uniquement \\ 5173 & HTTP & SvelteKit dev server (dev seul) & Localhost uniquement \\ - 3306 & MySQL/MariaDB & Connexion base de données & Localhost ou réseau privé \\ + 3306 & MySQL/MariaDB & Connexion base de données & Voir ci-dessous \\ \bottomrule \end{tabularx} \begin{keshwarning} \textbf{Ne jamais exposer le port 80 (API Axum) ni le port 3306 (MariaDB) directement sur Internet}. Toujours passer par un reverse proxy HTTPS qui termine TLS et fait suivre vers \texttt{localhost}. + +\textbf{Le port 3306 n'est pas publié par \keshpath{docker-compose.yml}} (depuis la 0.13.0) : Kesh atteint MariaDB par le réseau interne de Docker (hôte \texttt{mariadb}), et l'accès à la base passe par \keshcommand{docker compose exec mariadb}. Ne le publiez pas : \textbf{un port publié par Docker contourne le pare-feu de l'hôte} --- Docker insère ses propres règles \texttt{iptables} avant celles d'UFW, si bien que \keshcommand{ufw default deny incoming} (\S\ref{sec:firewall}) ne le ferme pas. Pour un dépannage ponctuel, le compose porte en commentaire une forme liée au seul loopback (\texttt{"127.0.0.1:3306:3306"}), à recommenter ensuite. Avec \keshpath{docker-compose.prod.yml} (Synology), la base n'est pas dans le compose : c'est la vôtre, et son port 3306 reste limité à localhost ou au réseau privé. \end{keshwarning} \subsubsection{Changer le port d'écoute (conflit port 80, ex. Synology DSM Web Station)} @@ -239,7 +241,11 @@ \subsubsection{Étape 3 — Créer le fichier \texttt{.env}} \textbf{\texttt{KESH\_JWT\_SECRET} est obligatoire et se génère} : générez le secret (\keshcommand{openssl rand -hex 32}) et collez-le à la place du placeholder ; vérifiez : \keshcommand{grep KESH\_JWT\_SECRET .env} ne doit montrer ni \texttt{GENERATE\_ME}, ni \texttt{change-me}, ni valeur entre chevrons \texttt{<…>}. Sans secret, Kesh refuse de démarrer, y compris avec \keshpath{docker-compose.yml}, dont le défaut \texttt{change-me…} est refusé à dessein ; le placeholder \texttt{} laissé tel quel est refusé depuis la 0.13.0. Les variables \texttt{KESH\_ADMIN\_*} restent optionnelles (onboarding \keshpath{/setup}). Avec \keshpath{docker-compose.yml}, \texttt{DATABASE\_URL} est \textbf{composée} des \texttt{MARIADB\_*} par le compose : la ligne \texttt{DATABASE\_URL} de \keshpath{.env} y est ignorée (elle sert \keshpath{docker-compose.prod.yml}). -Remplacez chaque valeur entre chevrons \texttt{<…>} : recopiée telle quelle, elle fait refuser le démarrage (depuis la 0.13.0) pour \texttt{KESH\_JWT\_SECRET} et \texttt{KESH\_ADMIN\_PASSWORD} ; pour les mots de passe MariaDB, rien ne la refuse — choisissez-les vous-même. +Remplacez chaque valeur entre chevrons \texttt{<…>} : recopiée telle quelle, elle fait refuser le démarrage (depuis la 0.13.0) pour \texttt{KESH\_JWT\_SECRET} et \texttt{KESH\_ADMIN\_PASSWORD}. + +\textbf{Les deux mots de passe MariaDB sont obligatoires avec \keshpath{docker-compose.yml}} (depuis la 0.13.0) : sur une \textbf{installation neuve}, générez chacun par \keshcommand{openssl rand -hex 32} --- en hexadécimal, car une sortie base64 peut contenir \texttt{/}, qui casse une \texttt{DATABASE\_URL}. Sans eux, \textbf{Compose refuse de démarrer}, au terminal, en nommant la variable manquante --- une seule à la fois, pas toujours la même : posez les deux avant de relancer. Une valeur entre chevrons recopiée, en revanche, est acceptée par Compose et par MariaDB : Kesh \textbf{avertit au démarrage} quand c'est le mot de passe applicatif (\texttt{MARIADB\_PASSWORD}, qu'il lit dans \texttt{DATABASE\_URL}) ; \textbf{rien ne signale un mot de passe root resté au gabarit} --- Kesh ne le voit pas. Relisez ces deux lignes. + +\textbf{MariaDB ne lit ces deux valeurs qu'à la création de la base} (premier démarrage, volume vide) : les changer ensuite dans \keshpath{.env} ne change pas le mot de passe enregistré, et Kesh échoue alors à se connecter (erreur 1045). Une base \textbf{déjà créée} avec la chaîne gabarit littérale --- \texttt{} ou \texttt{}, recopiées d'une version antérieure de ce manuel --- garde ce mot de passe : ne le remplacez pas dans \keshpath{.env}. De même, une base créée avant la 0.13.0 sans mot de passe posé garde les anciens défauts \texttt{kesh\_dev\_root} et \texttt{kesh\_dev} : écrivez-les dans \keshpath{.env}, jamais une valeur neuve (\S\ref{sec:maj-0-13}). Dans les deux cas, changez-le par la procédure du \S\ref{sec:mariadb-mdp} (\texttt{ALTER USER} d'abord, \keshpath{.env} ensuite). \begin{lstlisting}[language=bash] # Secret JWT — générer avec : openssl rand -hex 32 (minimum 32 octets) @@ -249,13 +255,15 @@ \subsubsection{Étape 3 — Créer le fichier \texttt{.env}} # KESH_ADMIN_USERNAME=admin # KESH_ADMIN_PASSWORD= -# Mot de passe DB (conteneur MariaDB) -MARIADB_ROOT_PASSWORD= -MARIADB_PASSWORD= +# Mots de passe MariaDB : OBLIGATOIRES avec docker-compose.yml, lus à +# la création de la base seulement. Chacun : openssl rand -hex 32 +MARIADB_ROOT_PASSWORD= +MARIADB_PASSWORD= MARIADB_DATABASE=kesh MARIADB_USER=kesh -# URL de connexion DB (service MariaDB nommé "mariadb" dans docker-compose) +# URL de connexion DB : pour docker-compose.prod.yml seulement +# (docker-compose.yml la compose lui-même). Mot de passe hexadécimal. DATABASE_URL=mysql://kesh:@mariadb:3306/kesh \end{lstlisting} @@ -543,7 +551,8 @@ \subsubsection{Étape 2 — Récupérer docker-compose.yml et .env} # Secrets obligatoires : KESH_JWT_SECRET, DATABASE_URL (et les mots de # passe de votre MariaDB) -- ils DOIVENT remplacer les placeholders de .env openssl rand -hex 32 # → KESH_JWT_SECRET -openssl rand -base64 32 # → mot de passe MariaDB (repris dans DATABASE_URL) +openssl rand -hex 32 # → mot de passe MariaDB (repris dans DATABASE_URL) +# hexadécimal : un « / » de base64 casserait DATABASE_URL # Optionnel : sans KESH_ADMIN_PASSWORD, l'administrateur se crée à l'écran # /setup ; si vous décommentez la ligne, remplacez son placeholder openssl rand -base64 24 # → KESH_ADMIN_PASSWORD @@ -689,8 +698,13 @@ \subsection{Variables d'environnement complètes}\label{sec:env-vars} \begin{sloppypar} \emph{Note} — \texttt{MARIADB\_ROOT\_PASSWORD}, \texttt{MARIADB\_USER}, \texttt{MARIADB\_PASSWORD}, \texttt{MARIADB\_DATABASE} ne sont pas lues par -\texttt{kesh-api} : elles configurent le \textbf{conteneur MariaDB} dans -\texttt{docker-compose}. \texttt{DATABASE\_URL} doit rester cohérente avec elles. +\texttt{kesh-api} : elles configurent le \textbf{conteneur MariaDB} de +\keshpath{docker-compose.yml}, qui en compose \texttt{DATABASE\_URL}. Les deux +mots de passe y sont \textbf{obligatoires} (sans eux, Compose refuse de +démarrer) et ne sont lus par MariaDB qu'à la \textbf{création} de la base --- +pour les changer : \S\ref{sec:mariadb-mdp}. \keshpath{docker-compose.prod.yml} +ne les lit pas : \texttt{DATABASE\_URL} y vient de \keshpath{.env} (mot de passe +hexadécimal). \end{sloppypar} \paragraph{Serveur \& réseau.}\mbox{}\\ @@ -956,7 +970,7 @@ \subsubsection{Initialisation manuelle de la base} \begin{lstlisting}[language=bash] mariadb -u root -p <'; GRANT ALL PRIVILEGES ON kesh.* TO 'kesh'@'%'; FLUSH PRIVILEGES; SQL @@ -1441,9 +1455,9 @@ \subsection{Les PDF des factures émises}\label{sec:pdf-factures-sauvegarde} \textbf{Un fichier altéré} (empreinte différente de celle enregistrée) n'est \textbf{jamais servi} : le téléchargement répond une erreur serveur et la journalise. Le refigeage le refuse aussi (\texttt{409 INVOICE\_PDF\_INTEGRITY}) : un fichier altéré se \textbf{diagnostique}, il ne se recouvre pas. Restaurer \texttt{\{empreinte\}.pdf} depuis la sauvegarde ; à défaut, \textbf{écarter} le fichier altéré (le déplacer hors du répertoire), ce qui ramène au cas du fichier manquant, puis refiger. \end{keshwarning} -\subsection{Script de sauvegarde mariadb-dump} +\subsection{Script de sauvegarde mariadb-dump}\label{sec:script-sauvegarde} -Voici un script bash de référence à exécuter via cron quotidien : +Voici un script bash de référence à exécuter via cron quotidien. Il lit le mot de passe root \textbf{dans le conteneur} (la commande entière passe à \keshcommand{sh -c} entre apostrophes) : il n'a besoin d'aucune variable posée dans la crontab. \texttt{cron} ne le lance pas depuis le répertoire du compose : \texttt{COMPOSE\_DIR} le lui donne. \begin{lstlisting}[language=bash] #!/bin/bash @@ -1452,24 +1466,20 @@ \subsection{Script de sauvegarde mariadb-dump} set -euo pipefail +COMPOSE_DIR="/opt/kesh" # répertoire de docker-compose.yml BACKUP_DIR="/var/backups/kesh" RETENTION_DAYS=30 TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="${BACKUP_DIR}/kesh_${TIMESTAMP}.sql.gz" mkdir -p "${BACKUP_DIR}" +cd "${COMPOSE_DIR}" -# Dump avec gzip et stockage hash SHA-256 -docker compose exec -T db \ - mariadb-dump \ - --single-transaction \ - --routines \ - --triggers \ - --events \ - --add-drop-database \ - --databases kesh \ - -u root \ - -p"${MARIADB_ROOT_PASSWORD}" \ +# Dump gzip et hash SHA-256 ; mot de passe root lu dans le conteneur +docker compose exec -T mariadb sh -c \ + 'mariadb-dump --single-transaction --routines --triggers \ + --events --add-drop-database --databases kesh \ + -u root -p"$MARIADB_ROOT_PASSWORD"' \ | gzip > "${BACKUP_FILE}" # Hash SHA-256 pour verification ulterieure (OLICo Art. 9 al. 1.b ch. 1) @@ -1488,6 +1498,10 @@ \subsection{Script de sauvegarde mariadb-dump} 0 2 * * * /usr/local/bin/kesh-backup.sh >> /var/log/kesh-backup.log 2>&1 \end{lstlisting} +\begin{keshwarning} +\textbf{Lancez le script une fois à la main} après son installation, après toute mise à jour et après tout changement de mot de passe MariaDB : un échec la nuit ne s'écrit que dans \keshpath{/var/log/kesh-backup.log}, que personne ne lit. C'est en particulier le seul signal d'un mot de passe root changé dans \keshpath{.env} sans \texttt{ALTER USER} (\S\ref{sec:mariadb-mdp}). +\end{keshwarning} + \subsection{Stockage offsite} Synchroniser les sauvegardes vers un stockage distant (S3, Backblaze B2, Wasabi, Hetzner Storage Box, OVH C14, etc.) : @@ -1510,9 +1524,12 @@ \subsection{Restauration depuis sauvegarde} # 2. Arreter Kesh (pas la DB) docker compose stop kesh-api -# 3. Restaurer la base -zcat kesh_20260520_020000.sql.gz | docker compose exec -T db \ - mariadb -u root -p"${MARIADB_ROOT_PASSWORD}" +# 3. Restaurer la base (depuis le repertoire du compose, COMPOSE_DIR du +# script de sauvegarde ; mot de passe root lu dans le conteneur) +cd /opt/kesh +zcat /var/backups/kesh/kesh_20260520_020000.sql.gz \ + | docker compose exec -T mariadb \ + sh -c 'mariadb -u root -p"$MARIADB_ROOT_PASSWORD"' # 4. Restaurer le repertoire des documents (PDF des factures emises, # justificatifs) depuis SA sauvegarde, prise en meme temps que le dump @@ -1757,7 +1774,7 @@ \subsection{Procédure de mise à jour standard} \begin{enumerate} \item \textbf{Sauvegarder} la base de données et le volume \texttt{kesh\_db\_data} (cf. section précédente). \item \textbf{Lire le changelog} de la nouvelle version pour identifier les breaking changes éventuels et les migrations nécessaires. - \item \textbf{Mettre à jour le fichier compose} --- \textbf{obligatoire pour passer à la 0.13.0} (\S\ref{sec:maj-0-13}) : re-télécharger le compose et y reporter ses adaptations locales, puis vérifier les placeholders de \keshpath{.env}. Pour les versions suivantes : selon le changelog (nouvelles variables, changement d'image). + \item \textbf{Mettre à jour le fichier compose} --- \textbf{obligatoire pour passer à la 0.13.0} (\S\ref{sec:maj-0-13}) : re-télécharger le compose et y reporter ses adaptations locales, puis vérifier les placeholders de \keshpath{.env} --- dont les lignes \texttt{MARIADB\_ROOT\_PASSWORD} et \texttt{MARIADB\_PASSWORD} : une valeur entre chevrons y est acceptée par Compose, et pour root rien d'autre ne la signale. Sur une base déjà créée, \textbf{ne remplacez pas} ces deux valeurs dans \keshpath{.env} --- ni des chevrons, ni \texttt{kesh\_dev} : la base a gardé celles de sa création, et une valeur neuve n'y change rien (1045 pour Kesh, sauvegarde en échec pour root) ; changez-les par le \S\ref{sec:mariadb-mdp}, \texttt{ALTER USER} d'abord. Pour les versions suivantes : selon le changelog (nouvelles variables, changement d'image). \item \textbf{Tirer la nouvelle image} : \keshcommand{docker compose pull kesh-api}. \item \textbf{Redémarrer} : \keshcommand{docker compose up -d kesh-api}. \item \textbf{Vérifier} les logs : \keshcommand{docker compose logs -f kesh-api} pour s'assurer que les migrations DB se sont bien déroulées. @@ -1775,37 +1792,46 @@ \subsubsection{Passer à la 0.13.0 : le compose et le fichier \texttt{.env}}\lab \begin{keshwarning} \textbf{Relisez votre \keshpath{.env} avant de mettre à jour : des lignes jusqu'ici sans effet le deviennent.} +\textbf{Refus de Compose} (\keshpath{docker-compose.yml}), \textbf{avant} Kesh : sans \texttt{MARIADB\_ROOT\_PASSWORD} ou \texttt{MARIADB\_PASSWORD} dans \keshpath{.env}, ou vide, la commande s'arrête \textbf{au terminal} en nommant la variable (\texttt{required variable … is missing a value}) et aucun conteneur ne démarre --- voir le paragraphe \emph{MariaDB} ci-dessous. + \textbf{Refus de démarrer} --- le message nomme la variable (\keshcommand{docker compose logs kesh-api}) : \begin{itemize} \item \texttt{KESH\_SMTP\_TLS} ou \texttt{KESH\_FEATURE\_FORGOT\_PASSWORD} d'une valeur autre que \texttt{true}/\texttt{false}/\texttt{1}/\texttt{0} (espaces de tête et de fin ignorés), sur les deux compose --- même sans le mot de passe oublié pour \texttt{KESH\_SMTP\_TLS} ; \item \texttt{KESH\_COOKIE\_SECURE} d'une valeur autre que \texttt{true}/\texttt{false}/\texttt{1}/\texttt{0} (espaces de tête et de fin ignorés), sur \keshpath{docker-compose.prod.yml}, où il était ignoré ; \item \texttt{KESH\_FEATURE\_FORGOT\_PASSWORD=true} avec l'une des cinq \texttt{KESH\_SMTP\_HOST}/\texttt{\_USER}/\texttt{\_PASSWORD}/\texttt{\_FROM}, \texttt{KESH\_PUBLIC\_BASE\_URL} absente, un \texttt{KESH\_SMTP\_FROM} mal formé (« Nom ») ou un \texttt{KESH\_SMTP\_HOST} au format \texttt{hôte:port} ; \item \texttt{KESH\_FEATURE\_FORGOT\_PASSWORD=true} avec une configuration SMTP complète dont le mailer ne se construit pas (adresse \texttt{KESH\_SMTP\_FROM} refusée, échec de l'initialisation STARTTLS) : « le build du mailer SMTP a échoué … abandon » (sans le mot de passe oublié, le même échec rend seulement l'envoi de factures indisponible) ; - \item \texttt{KESH\_JWT\_SECRET} resté au placeholder du gabarit ou à une valeur entre chevrons ; \texttt{KESH\_ADMIN\_PASSWORD} décommenté sans remplacer le sien, ou recopié d'un exemple de ce manuel (\texttt{}, \texttt{}). + \item \texttt{KESH\_JWT\_SECRET} resté au placeholder du gabarit ou à une valeur entre chevrons ; \texttt{KESH\_ADMIN\_PASSWORD} décommenté sans remplacer le sien, ou recopié d'un exemple de ce manuel (\texttt{}, \texttt{}) ; + \item \texttt{MARIADB\_PASSWORD} changé dans \keshpath{.env} sans \texttt{ALTER USER} : « Base de données indisponible … 1045 … », suivi d'un indice (\S\ref{sec:mariadb-mdp}). \end{itemize} \textbf{Effets sans refus} : \texttt{KESH\_PRODUCTION\_RESET} posé lors d'une ancienne démonstration réautorise, \textbf{sur une installation restée en démonstration}, la réinitialisation, qui vide toutes les données de la société ; les quatre \texttt{KESH\_SMTP\_HOST}/\texttt{\_USER}/\texttt{\_PASSWORD}/\texttt{\_FROM} posées « pour plus tard » activent l'envoi de factures par e-mail, et avec \texttt{KESH\_FEATURE\_FORGOT\_PASSWORD=true} (et \texttt{KESH\_PUBLIC\_BASE\_URL}) la réinitialisation du mot de passe par e-mail --- quiconque connaît l'adresse d'un utilisateur peut alors demander un lien ; \texttt{KESH\_COOKIE\_SECURE=false} sur \keshpath{docker-compose.prod.yml} émet des cookies sans \texttt{Secure} ; \texttt{KESH\_ADMIN\_BACKUP\_DIR} (un chemin non monté fait écrire dans le conteneur) ; \texttt{KESH\_LANG} ; \texttt{KESH\_PASSWORD\_MIN\_LENGTH} sur \keshpath{docker-compose.yml} s'applique côté serveur, alors que trois écrans gardent 12 en dur : au-dessous de 12, les écrans refusent quand même ; au-dessus, l'écran accepte et le serveur refuse ; une ligne \texttt{KESH\_LOG\_FILE\_PATH=} \textbf{vide} désactive désormais le journal fichier ; et plus généralement toute ligne décommentée de ces seize variables. -\textbf{Un \keshpath{.env} copié du gabarit sans modification} ne change pas de comportement --- les seules lignes actives du gabarit parmi ces variables valent leur défaut (\texttt{KESH\_LANG=fr}, \texttt{KESH\_PASSWORD\_MIN\_LENGTH=12}, \texttt{KESH\_BANK\_IMPORT\_MAX\_MB=10}, \texttt{KESH\_COOKIE\_SECURE=true}) --- \textbf{sauf} le secret JWT laissé au placeholder, désormais refusé. Un \texttt{KESH\_ADMIN\_PASSWORD} absent ou vide ne change rien avec \keshpath{docker-compose.prod.yml}, et cesse de faire refuser le démarrage avec \keshpath{docker-compose.yml}. +\textbf{Un \keshpath{.env} copié du gabarit sans modification} ne change pas de comportement --- les seules lignes actives du gabarit parmi ces variables valent leur défaut (\texttt{KESH\_LANG=fr}, \texttt{KESH\_PASSWORD\_MIN\_LENGTH=12}, \texttt{KESH\_BANK\_IMPORT\_MAX\_MB=10}, \texttt{KESH\_COOKIE\_SECURE=true}) --- \textbf{sauf} le secret JWT laissé au placeholder, désormais refusé, \textbf{et} les mots de passe MariaDB avec \keshpath{docker-compose.yml} : copié du gabarit \textbf{0.13.0}, où ils sont commentés et sans valeur, il fait refuser Compose ; copié d'un gabarit \textbf{antérieur}, qui posait \texttt{kesh\_dev}, il fait émettre à Kesh l'avertissement « mot de passe publié ». Un \texttt{KESH\_ADMIN\_PASSWORD} absent ou vide ne change rien avec \keshpath{docker-compose.prod.yml}, et cesse de faire refuser le démarrage avec \keshpath{docker-compose.yml}. \end{keshwarning} \textbf{Voie recommandée : re-télécharger le compose} --- \keshpath{docker-compose.yml}, ou, sur Synology, \keshpath{docker-compose.prod.yml} enregistré sous \keshpath{docker-compose.yml} (\S\ref{sec:synology}) --- \textbf{et y reporter vos seules adaptations locales} (port \texttt{8080:80}, réseau, chemins des montages). Un montage modifié à la main et non reporté revient à \keshpath{./documents} (ou \keshpath{./inbox}, \keshpath{./log}) : justificatifs et PDF paraissent alors disparus --- ils sont toujours dans l'ancien dossier. Les commandes s'exécutent depuis le répertoire du compose (\keshpath{/volume1/docker/kesh} sur Synology) ; avec un autre nom de fichier, ajoutez \keshcommand{-f } à chaque commande \keshcommand{docker compose}. Puis \keshcommand{docker compose pull} \textbf{avant} \keshcommand{docker compose up -d}. +\paragraph{MariaDB (\keshpath{docker-compose.yml}) : port fermé, mots de passe obligatoires.}\mbox{}\\ +\begin{sloppypar} +Le compose 0.13.0 ne publie plus le port 3306 de MariaDB --- le re-télécharger suffit à le fermer ; un outil de l'hôte qui s'y connectait passe désormais par \keshcommand{docker compose exec mariadb …} --- et n'a plus de mots de passe MariaDB par défaut. Avec un \keshpath{.env} sans \texttt{MARIADB\_ROOT\_PASSWORD} ou sans \texttt{MARIADB\_PASSWORD}, \textbf{Compose refuse} \keshcommand{config}, \keshcommand{pull} et \keshcommand{up} en nommant la variable, au terminal ; \keshcommand{ps}, \keshcommand{logs}, \keshcommand{exec} et \keshcommand{stop} restent utilisables (mesuré avec Docker Compose 2.40), de quoi diagnostiquer et arrêter. Écrivez alors dans \keshpath{.env} \textbf{la valeur avec laquelle la base a été créée, jamais une neuve} : si vous n'en aviez jamais posé, ce sont les anciennes valeurs par défaut, \texttt{MARIADB\_ROOT\_PASSWORD=kesh\_dev\_root} et \texttt{MARIADB\_PASSWORD=kesh\_dev}. Une valeur neuve ne change pas le mot de passe enregistré : pour \texttt{MARIADB\_PASSWORD}, Kesh échoue à se connecter (erreur 1045, avec un indice) ; pour \texttt{MARIADB\_ROOT\_PASSWORD}, \textbf{rien ne le signale}, mais la sauvegarde nocturne échoue. Démarrez, puis \textbf{changez ces mots de passe} par la procédure du \S\ref{sec:mariadb-mdp} : publiés dans le dépôt Kesh, ils ne protègent rien --- Kesh avertit au démarrage tant que celui de \texttt{DATABASE\_URL} vaut \texttt{kesh\_dev} ou une valeur gabarit \texttt{<…>}. Terminez par le \textbf{contrôle de fin de mise à jour} ci-dessous. +\end{sloppypar} + \begin{sloppypar} -\textbf{Vérifier les placeholders} (Kesh 0.13.0 refuse de démarrer si \texttt{KESH\_JWT\_SECRET} ou \texttt{KESH\_ADMIN\_PASSWORD} est resté au placeholder \texttt{} du gabarit ou à une valeur entre chevrons \texttt{<…>}). Le contrôle se demande à Compose lui-même, le compose final en place --- Compose lit \keshpath{.env} à sa façon (\texttt{export}, espaces autour de \texttt{=}, guillemets, fins de ligne Windows), qu'aucun \texttt{grep} sur \keshpath{.env} n'imite sûrement : +\textbf{Vérifier les placeholders} (Kesh 0.13.0 refuse de démarrer si \texttt{KESH\_JWT\_SECRET} ou \texttt{KESH\_ADMIN\_PASSWORD} est resté au placeholder \texttt{} du gabarit ou à une valeur entre chevrons \texttt{<…>}). Le contrôle se demande à Compose lui-même, le compose final en place --- Compose lit \keshpath{.env} à sa façon (\texttt{export}, espaces autour de \texttt{=}, guillemets, fins de ligne Windows), qu'aucun \texttt{grep} sur \keshpath{.env} n'imite sûrement. Les deux mots de passe MariaDB posés (paragraphe précédent), demandez d'abord à Compose s'il lit le fichier, puis cherchez les placeholders : \end{sloppypar} \begin{lstlisting}[language=bash] +sudo docker compose config -q && echo 'compose lisible' sudo docker compose config \ | grep -iE 'KESH_(JWT_SECRET|ADMIN_PASSWORD): .? *(<|.*generate_me)' \end{lstlisting} \begin{sloppypar} -La commande doit rester \textbf{muette}. Dans le cas qu'elle détecte, elle affiche la ligne trouvée --- un placeholder, non un secret. Elle est un sur-ensemble du refus de Kesh (une valeur qui commence par \texttt{<} sans finir par \texttt{>} y apparaît aussi, que Kesh accepte). Si elle montre \texttt{KESH\_JWT\_SECRET} : générez le secret (\keshcommand{openssl rand -hex 32}) \textbf{avant} le \keshcommand{up -d} --- les sessions ouvertes sont alors perdues, chacun se reconnecte. Si elle montre \texttt{KESH\_ADMIN\_PASSWORD} : choisissez un vrai mot de passe ($\geq$ 12 caractères) ou recommentez la ligne (onboarding \keshpath{/setup} sur une base vide, sans effet sur une base qui a déjà un administrateur). Faute de quoi Kesh refuse de démarrer. +La première commande \textbf{doit} afficher \texttt{compose lisible} : si Compose refuse le fichier, la seconde ne reçoit rien et reste muette --- muette comme un succès. La seconde doit rester \textbf{muette}. Dans le cas qu'elle détecte, elle affiche la ligne trouvée --- un placeholder, non un secret. Elle est un sur-ensemble du refus de Kesh (une valeur qui commence par \texttt{<} sans finir par \texttt{>} y apparaît aussi, que Kesh accepte). Si elle montre \texttt{KESH\_JWT\_SECRET} : générez le secret (\keshcommand{openssl rand -hex 32}) \textbf{avant} le \keshcommand{up -d} --- les sessions ouvertes sont alors perdues, chacun se reconnecte. Si elle montre \texttt{KESH\_ADMIN\_PASSWORD} : choisissez un vrai mot de passe ($\geq$ 12 caractères) ou recommentez la ligne (onboarding \keshpath{/setup} sur une base vide, sans effet sur une base qui a déjà un administrateur). Faute de quoi Kesh refuse de démarrer. Ce contrôle ne voit pas les mots de passe MariaDB : relisez à l'éditeur les lignes \texttt{MARIADB\_ROOT\_PASSWORD} et \texttt{MARIADB\_PASSWORD} de \keshpath{.env} --- pour un root resté au gabarit \texttt{<…>}, c'est le seul signal. \textbf{Contrairement aux deux variables précédentes, ne remplacez pas ces chevrons dans \keshpath{.env}} : la base a été créée avec cette chaîne et la garde ; changez le mot de passe par le \S\ref{sec:mariadb-mdp} (\texttt{ALTER USER} d'abord, \keshpath{.env} ensuite). \end{sloppypar} -\paragraph{Pour qui garde son fichier compose : deux gestes.}\mbox{}\\ -Sous \texttt{environment:} du service \texttt{kesh-api} : +\paragraph{Pour qui garde son fichier compose.}\mbox{}\\ +Quatre gestes pour \keshpath{docker-compose.yml}, deux pour \keshpath{docker-compose.prod.yml}. Les deux premiers, dans les deux fichiers, sous \texttt{environment:} du service \texttt{kesh-api} : \begin{enumerate} \item \textbf{ajouter} les lignes \textbf{nouvelles}, et elles seules --- pour \keshpath{docker-compose.yml} : \begin{lstlisting}[language=bash] @@ -1846,9 +1872,87 @@ \subsubsection{Passer à la 0.13.0 : le compose et le fichier \texttt{.env}}\lab KESH_ADMIN_PASSWORD: ${KESH_ADMIN_PASSWORD:-} KESH_LOG_FILE_PATH: ${KESH_LOG_FILE_PATH-/var/log/kesh/kesh.log} \end{lstlisting} + \item \begin{sloppypar}\keshpath{docker-compose.yml} seulement, service \texttt{mariadb} : \textbf{retirer} la clé \texttt{ports:} et sa ligne \texttt{- "3306:3306"} ;\end{sloppypar} + \item \begin{sloppypar}\keshpath{docker-compose.yml} seulement : \textbf{remplacer} les fragments \texttt{:-kesh\_dev\_root} et \texttt{:-kesh\_dev} --- trois occurrences : \texttt{MARIADB\_ROOT\_PASSWORD} et \texttt{MARIADB\_PASSWORD} du service \texttt{mariadb}, \texttt{DATABASE\_URL} de \texttt{kesh-api} --- par \texttt{:?} suivi d'un message, par exemple :\end{sloppypar} +\begin{lstlisting}[language=bash] + MARIADB_PASSWORD: ${MARIADB_PASSWORD:?à poser dans .env} +\end{lstlisting} + Exemple abrégé : le message du compose re-téléchargé ajoute « installation existante, reprendre la valeur avec laquelle la base a été créée, jamais une neuve », que Compose affiche au refus. Le plus sûr est de recopier ces trois lignes, message compris, depuis le compose re-téléchargé. \end{enumerate} \begin{sloppypar} -Puis \keshcommand{docker compose config -q} (qui refuse une clé en double), vérifier que \keshcommand{docker compose config} montre \texttt{KESH\_SMTP\_HOST} et \texttt{KESH\_PRODUCTION\_RESET}, et \keshcommand{docker compose up -d}. +Puis \keshcommand{docker compose config -q} (qui refuse une clé en double, et l'absence des deux mots de passe MariaDB), vérifier que \keshcommand{docker compose config} montre \texttt{KESH\_SMTP\_HOST} et \texttt{KESH\_PRODUCTION\_RESET}, et \textbf{aucune} entrée \texttt{ports} sous le service \texttt{mariadb} (Compose l'écrirait sous sa forme développée, \texttt{published: "3306"}), et \keshcommand{docker compose up -d}. +\end{sloppypar} + +\paragraph{Contrôle de fin de mise à jour (MariaDB, \keshpath{docker-compose.yml}).}\mbox{}\\ +Il se fait \textbf{sur la base}, pas sur \keshpath{.env} : un \keshcommand{grep} sur \keshcommand{docker compose config} ne lit que \keshpath{.env}, et resterait muet alors que la base garde l'ancien mot de passe. Une fois les mots de passe changés (\S\ref{sec:mariadb-mdp}) et \keshcommand{docker compose up -d} passé : + +\begin{lstlisting}[language=bash] +# Anciens mots de passe publiés : chaque commande doit être REFUSÉE (1045) +sudo docker compose exec -T mariadb mariadb -uroot -pkesh_dev_root \ + -e 'SELECT CURRENT_USER()' +sudo docker compose exec -T mariadb mariadb --protocol=TCP \ + -h 127.0.0.1 -uroot -pkesh_dev_root -e 'SELECT CURRENT_USER()' +sudo docker compose exec -T mariadb mariadb --protocol=TCP \ + -h 127.0.0.1 -ukesh -pkesh_dev -e 'SELECT CURRENT_USER()' +# Mots de passe de .env, lus dans le conteneur : chaque commande RÉUSSIT +sudo docker compose exec -T mariadb sh -c 'mariadb -uroot \ + -p"$MARIADB_ROOT_PASSWORD" -e "SELECT CURRENT_USER()"' +sudo docker compose exec -T mariadb sh -c 'mariadb --protocol=TCP \ + -h 127.0.0.1 -uroot -p"$MARIADB_ROOT_PASSWORD" \ + -e "SELECT CURRENT_USER()"' +sudo docker compose exec -T mariadb sh -c 'mariadb --protocol=TCP \ + -h 127.0.0.1 -ukesh -p"$MARIADB_PASSWORD" -e "SELECT CURRENT_USER()"' +\end{lstlisting} + +\begin{sloppypar} +Les trois premières doivent échouer (\texttt{ERROR 1045}), les trois dernières afficher le compte qui a répondu. Sans \texttt{-{}-protocol=TCP}, la connexion passe par le socket et atteint le compte \texttt{root@localhost} ; en TCP vers \texttt{127.0.0.1}, le compte \texttt{root@\%} (le serveur ne résout pas les noms) --- MariaDB exige le mot de passe dans les deux cas (mesuré, MariaDB 10.11). D'où les deux contrôles de root. Puis \textbf{lancez une fois à la main le script de sauvegarde} (\S\ref{sec:script-sauvegarde}) : c'est le seul moyen de voir qu'un mot de passe root changé dans \keshpath{.env} sans \texttt{ALTER USER} le fait échouer. +\end{sloppypar} + +\subsubsection{Changer un mot de passe MariaDB}\label{sec:mariadb-mdp} + +\begin{sloppypar} +Avec \keshpath{docker-compose.yml}. MariaDB ne relit pas \texttt{MARIADB\_ROOT\_PASSWORD} ni \texttt{MARIADB\_PASSWORD} après la création de la base : on change le mot de passe \textbf{dans la base} d'abord (\texttt{ALTER USER}), \textbf{puis} dans \keshpath{.env}, \textbf{puis} on recrée les conteneurs. L'ordre inverse produit un refus 1045 de Kesh, ou une sauvegarde en échec pour root. Lister d'abord les comptes : +\end{sloppypar} + +\begin{lstlisting}[language=bash] +cd /opt/kesh # répertoire du compose +sudo docker compose exec -T mariadb sh -c \ + 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" \ + -e "SELECT user, host FROM mysql.user"' +\end{lstlisting} + +L'image MariaDB crée deux comptes \texttt{root} (\texttt{root@localhost} et \texttt{root@\%}) et le compte applicatif, \texttt{kesh@\%} par défaut --- son nom est celui de \texttt{MARIADB\_USER}, que la recette lit dans le conteneur ; il faut une ligne \texttt{ALTER USER} par compte \texttt{root} listé. Puis : + +\begin{lstlisting}[language=bash] +cd /opt/kesh +( set -e # s'arrête à la première erreur + COMPTE=$(sudo docker compose exec -T mariadb printenv MARIADB_USER) + NEW_ROOT=$(openssl rand -hex 32) # jamais tapés : absents de + NEW_KESH=$(openssl rand -hex 32) # l'historique du shell + # Affichés AVANT d'être appliqués : recopiez-les dans .env, à + # l'éditeur, sans enregistrer encore + echo "MARIADB_ROOT_PASSWORD=$NEW_ROOT" + echo "MARIADB_PASSWORD=$NEW_KESH" + read -r -p 'Recopiés ? Entrée pour appliquer, Ctrl-C pour renoncer ' + sudo docker compose stop kesh-api + sudo docker compose exec -T mariadb \ + sh -c 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD"' <