From 13ee16e25d7914a43d03c58883d63ab0ac99810b Mon Sep 17 00:00:00 2001 From: PTA Date: Tue, 11 Aug 2026 11:53:19 +0200 Subject: [PATCH] =?UTF-8?q?D=C3=A9marrage=20de=20la=20pile=20par=20un=20si?= =?UTF-8?q?mple=20docker=20compose=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La pile de test de la branche ne démarrait pas : « Connexion à la base impossible ». Trois causes cumulées, toutes corrigées. 1. PTA_DB_PORT et DB_PORT étaient passés à 9306. Ces variables désignent le port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306, et non un port publié sur l'hôte : la connexion était refusée. Elles reviennent à 3306, et le port hôte 9306 est désormais réellement publié par le service db (sur la boucle locale), pour un client SQL externe. 2. Le service db-migrate manquait sur la branche. Le schéma d'initialisation ne contient aucune des tables et vues d'accès V7.8 : sur un volume neuf, l'application refusait de démarrer. db-migrate applique maintenant tous les database/migration_*.sql, dont celle du tableau de bord V7.9 — plus aucune étape SQL manuelle. 3. Le défaut de PTA_DB_PASS (change_me) différait de celui de MARIADB_PASSWORD (pta). Sans fichier .env — et ce répertoire n'en a pas, il est ignoré par git — l'authentification échouait. Les deux défauts sont alignés. Le bandeau du tableau de bord ne prétend plus que « tous les lieux ont des horaires attendus » quand aucun lieu n'est enregistré. Vérifié : volume détruit puis « docker compose up -d --build » seul suffit à obtenir les onze pages en 200, l'atterrissage sur le tableau de bord et les deux migrations appliquées ; un second up les ignore ; la base répond sur 127.0.0.1:9306 ; la pile principale sur 8080 est restée intacte. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 19 ++++- README_V7_9.md | 21 +++-- assets/js/dashboard.js | 15 ++-- docker-compose.yml | 103 +++++++++++++++++++---- docker/db/apply-migrations.sh | 153 ++++++++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 31 deletions(-) create mode 100755 docker/db/apply-migrations.sh diff --git a/CLAUDE.md b/CLAUDE.md index 44f1936..e0d0d93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,23 @@ find . -name '*.php' -not -path './.git/*' -exec php -l {} \; ``` Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué **qu'à la création initiale du volume** -MariaDB (`docker-entrypoint-initdb.d`). Sur une base existante, toute évolution de schéma doit être -appliquée manuellement en SQL — il n'y a pas d'outil de migration. +MariaDB (`docker-entrypoint-initdb.d`) : il ne contient que le socle métier, et **aucune** des tables +ou vues ajoutées par les migrations. + +Les fichiers `database/migration_*.sql` sont appliqués automatiquement au démarrage par le service +jetable `db-migrate` (`docker/db/apply-migrations.sh`), sur base neuve comme sur base existante. +Le service `php` attend sa sortie en succès : une migration en échec empêche l'application de +tourner sur une base incomplète, plutôt que de la laisser renvoyer une erreur 500 à l'accueil. + +Le script tient un registre `schema_migration` (fichier + empreinte MD5) : un fichier déjà appliqué +et inchangé est ignoré, un fichier modifié est rejoué. **Toute migration doit donc rester +idempotente** (`CREATE TABLE IF NOT EXISTS`, `CREATE OR REPLACE VIEW`, +`INSERT … ON DUPLICATE KEY UPDATE`). Les fichiers sont pris dans l'ordre alphabétique : nommer une +migration `migration_v7_10_…` la ferait passer **avant** `migration_v7_9_…`. + +`PTA_DB_PORT`, `DB_PORT` et `PMA_PORT` du compose sont les ports joints **à l'intérieur** du réseau +Docker, où MariaDB écoute sur 3306. Ce ne sont pas les ports publiés sur l'hôte (`APP_PORT`, +`PMA_PORT` de `.env`) : les modifier coupe l'accès à la base. ## Architecture diff --git a/README_V7_9.md b/README_V7_9.md index 560dd06..9fb1273 100644 --- a/README_V7_9.md +++ b/README_V7_9.md @@ -70,22 +70,25 @@ L'endpoint `api/dashboard_summary.php` est gardé par `requireService()` : 403 p ## Migration -```sql -SOURCE database/migration_v7_9_tableau_de_bord.sql; +Aucun geste manuel : `database/migration_v7_9_tableau_de_bord.sql` est appliqué au démarrage par le +service `db-migrate`, qui joue tous les `database/migration_*.sql` dans l'ordre. Un simple + +```bash +docker compose up -d --build ``` -Elle insère une seule ligne dans `acces_role_page`. **Elle doit être jouée en même temps que le -déploiement du code**, dans un sens comme dans l'autre : +suffit, sur base neuve comme sur base existante. La migration insère une seule ligne dans +`acces_role_page` et est idempotente (`ON DUPLICATE KEY UPDATE`). -- code déployé, migration non jouée → l'onglet n'apparaît pas, le Service Enfance atterrit comme - avant sur l'écran de saisie, et `dashboard.php` renvoie vers l'accueil. L'application reste - entièrement fonctionnelle, simplement sans tableau de bord ; -- migration jouée, code non déployé → l'onglet apparaît et pointe vers un fichier inexistant. +Si la migration n'était pas jouée, l'onglet n'apparaîtrait pas, le Service Enfance atterrirait comme +avant sur l'écran de saisie et `dashboard.php` renverrait vers l'accueil : l'application resterait +entièrement fonctionnelle, simplement sans tableau de bord. Retour arrière : ```sql DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard'; +DELETE FROM schema_migration WHERE fichier = 'migration_v7_9_tableau_de_bord.sql'; ``` ## Fichiers @@ -102,6 +105,8 @@ DELETE FROM acces_role_page WHERE role = 'SERVICE_ENFANCE' AND page = 'dashboard | Modifié | Raison | | --- | --- | +| `docker-compose.yml` | service `db-migrate`, port interne de la base rétabli à 3306, défaut de mot de passe aligné entre `db` et `php` | +| `docker/db/apply-migrations.sh` | application automatique et idempotente des migrations | | `app/Core/Access.php` | `homeUrl()` vise le tableau de bord, avec repli si la migration manque | | `app/Controllers/PageController.php` | méthode `dashboard()` et sous-titre propre à chaque page | | `app/Views/layouts/header.php` | navigation regroupée, sous-titre contextuel | diff --git a/assets/js/dashboard.js b/assets/js/dashboard.js index b236357..670a441 100644 --- a/assets/js/dashboard.js +++ b/assets/js/dashboard.js @@ -151,9 +151,12 @@ `).join(''); } - function renderCoverage(coverage, week) { + function renderCoverage(coverage, week, perimetre) { const gapCount = Number(coverage.gap_count || 0); const unconfigured = Number(coverage.unconfigured_count || 0); + // Sans aucun lieu enregistré, « tous les minimums sont atteints » serait + // vrai mais trompeur : il n'y a simplement rien à contrôler. + const sansLieu = Number(perimetre?.structures || 0) === 0; setTileTone(elements.coverageTile, gapCount); setTileTone(elements.setupTile, unconfigured); @@ -161,14 +164,14 @@ if (elements.coverageCount) elements.coverageCount.textContent = String(gapCount); if (elements.coverageDetail) { elements.coverageDetail.textContent = gapCount === 0 - ? 'Tous les minimums sont atteints' + ? (sansLieu ? 'Aucun lieu d’affectation enregistré' : 'Tous les minimums sont atteints') : `${formatDuration(Number(coverage.gap_minutes || 0))} non couvertes · ` + `${coverage.structure_count} ${plural(coverage.structure_count, 'lieu concerné', 'lieux concernés')}`; } if (elements.setupCount) elements.setupCount.textContent = String(unconfigured); if (elements.setupDetail) { elements.setupDetail.textContent = unconfigured === 0 - ? 'Tous les lieux ont des horaires attendus' + ? (sansLieu ? 'Aucun lieu à paramétrer pour l’instant' : 'Tous les lieux ont des horaires attendus') : 'Aucun minimum d’agents n’y est contrôlé'; } @@ -216,7 +219,9 @@ if (elements.quotaCount) elements.quotaCount.textContent = String(overCount); if (elements.quotaDetail) { elements.quotaDetail.textContent = overCount === 0 - ? `Aucun dépassement sur ${quota.annee}` + ? (Number(quota.agent_count || 0) === 0 + ? 'Aucun agent actif à suivre' + : `Aucun dépassement sur ${quota.annee}`) : `Sur ${quota.agent_count} ${plural(quota.agent_count, 'agent suivi', 'agents suivis')} en ${quota.annee}`; } if (elements.quotaTile) { @@ -301,7 +306,7 @@ renderWeek(data.semaine); renderPending(data.validation || {}); - renderCoverage(data.couverture || {}, data.semaine); + renderCoverage(data.couverture || {}, data.semaine, data.perimetre); renderQuota(data.quota || {}); } catch (error) { setMessage(error.message, 'error'); diff --git a/docker-compose.yml b/docker-compose.yml index 4b48065..ff53ef1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,36 @@ +# ============================================================================ +# Pile de test de la branche V7.9. +# ============================================================================ +# Noms de conteneurs, réseau, volume et ports publiés sont suffixés « test » : +# cette pile tourne EN PARALLÈLE de la pile principale (pta-db, pta-php, …) +# sans lui prendre ni ses conteneurs, ni ses données, ni ses ports. +# +# `docker compose up -d --build` suffit : le service db-migrate applique +# lui-même les migrations SQL, dont celle du tableau de bord V7.9. +# +# Ce répertoire n'a volontairement pas de fichier .env (il est ignoré par git) : +# les valeurs par défaut ci-dessous s'appliquent donc telles quelles, et les +# ports publiés (9080 / 9081 / 9306) ne peuvent pas entrer en conflit avec la +# pile principale (8080 / 8081). Si vous y copiez le .env de la pile principale, +# pensez à y changer APP_PORT et PMA_PORT. +# +# ATTENTION — deux familles de ports à ne pas confondre : +# +# · ports PUBLIÉS sur la machine hôte, dans les blocs `ports:` : +# 9080 application 9081 phpMyAdmin 9306 MariaDB +# Ceux-là se changent librement, ce sont eux qu'on tape dans un navigateur +# ou dans un client SQL. +# +# · ports JOINTS À L'INTÉRIEUR du réseau Docker, dans les blocs +# `environment:` (PTA_DB_PORT, DB_PORT, PMA_PORT) : MariaDB y écoute +# TOUJOURS sur 3306, quel que soit le port publié. Y mettre autre chose +# coupe l'accès à la base et l'application renvoie « Connexion à la base +# impossible ». +# ============================================================================ services: db: image: mariadb:11.4 - container_name: pta-db + container_name: pta-db-test restart: unless-stopped environment: MARIADB_DATABASE: ${DB_NAME:-pta} @@ -13,9 +42,14 @@ services: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci volumes: - - mariadb_data:/var/lib/mysql + - mariadb_data_test:/var/lib/mysql # Ce fichier n'est exécuté QUE lors de l'initialisation d'un volume vide. - ./database/pta_prod_schema_mariadb.sql:/docker-entrypoint-initdb.d/01-pta-schema.sql:ro + ports: + # Base joignable depuis la machine hôte pour un client SQL externe, sur la + # boucle locale uniquement. À l'intérieur du réseau Docker, les services + # continuent de joindre MariaDB sur son port natif 3306. + - "${DB_BIND:-127.0.0.1}:${DB_PORT_HOST:-9306}:3306" healthcheck: test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -p\"$${MARIADB_ROOT_PASSWORD}\" --silent"] interval: 10s @@ -23,21 +57,54 @@ services: retries: 10 start_period: 20s networks: - - pta + - pta-test + + # Service jetable : applique les migrations SQL puis s'arrête. Le service php + # ne démarre qu'après sa sortie en succès, si bien qu'une migration en échec + # empêche l'application de tourner sur une base incomplète. + db-migrate: + image: mariadb:11.4 + container_name: pta-db-migrate-test + restart: "no" + environment: + DB_HOST: db + # Port joint À L'INTÉRIEUR du réseau Docker : MariaDB y écoute sur 3306. + # Le port publié sur l'hôte (9306) n'a pas cours ici. + DB_PORT: 3306 + DB_NAME: ${DB_NAME:-pta} + # Les migrations créent tables et vues : elles passent par le compte + # d'administration, jamais par le compte applicatif. + DB_USER: root + DB_PASSWORD: ${DB_ROOT_PASSWORD:-pta} + TZ: ${TZ:-Europe/Paris} + volumes: + - ./database:/migrations:ro + - ./docker/db/apply-migrations.sh:/usr/local/bin/apply-migrations.sh:ro + entrypoint: ["/bin/sh", "/usr/local/bin/apply-migrations.sh"] + depends_on: + db: + condition: service_healthy + networks: + - pta-test php: build: context: . dockerfile: Dockerfile target: php - container_name: pta-php + container_name: pta-php-test restart: unless-stopped environment: PTA_DB_HOST: db + # Port joint À L'INTÉRIEUR du réseau Docker, où MariaDB écoute sur 3306. + # Ce n'est pas le port publié sur l'hôte : pour joindre la base depuis la + # machine, utiliser 127.0.0.1:9306, publié par le service db ci-dessus. PTA_DB_PORT: 3306 PTA_DB_NAME: ${DB_NAME:-pta} PTA_DB_USER: ${DB_USER:-pta} - PTA_DB_PASS: ${DB_PASSWORD:-change_me} + # Le défaut doit rester identique à MARIADB_PASSWORD du service db : + # deux défauts différents rendent la pile inutilisable sans fichier .env. + PTA_DB_PASS: ${DB_PASSWORD:-pta} # Aucun détail technique n'est renvoyé au navigateur tant que ce drapeau # n'est pas explicitement passé à 1. Ne jamais l'activer en production. PTA_DEBUG: ${PTA_DEBUG:-0} @@ -45,29 +112,32 @@ services: depends_on: db: condition: service_healthy + db-migrate: + condition: service_completed_successfully networks: - - pta + - pta-test nginx: build: context: . dockerfile: Dockerfile target: nginx - container_name: pta-nginx + container_name: pta-nginx-test restart: unless-stopped ports: - - "${APP_BIND:-0.0.0.0}:${APP_PORT:-8080}:80" + - "${APP_BIND:-0.0.0.0}:${APP_PORT:-9080}:80" depends_on: - php networks: - - pta + - pta-test phpmyadmin: image: phpmyadmin:5.2.2 - container_name: pta-phpmyadmin + container_name: pta-phpmyadmin-test restart: unless-stopped environment: PMA_HOST: db + # Port interne au réseau Docker, jamais un port publié. PMA_PORT: 3306 # PMA_USER / PMA_PASSWORD sont volontairement absents : leur présence fait # basculer phpMyAdmin en auth_type=config, c'est-à-dire une session déjà @@ -76,18 +146,19 @@ services: UPLOAD_LIMIT: 64M TZ: ${TZ:-Europe/Paris} ports: - # phpMyAdmin n'écoute que sur la boucle locale. Pour y accéder à distance, - # passer par un tunnel SSH (voir README_DOCKER.md). - - "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80" + # phpMyAdmin n'écoute que sur la boucle locale : il donne un accès complet + # à la base. Pour y accéder à distance, passer par un tunnel SSH + # (voir README_DOCKER.md), plutôt que d'ouvrir ce port sur le réseau. + - "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-9081}:80" depends_on: db: condition: service_healthy networks: - - pta + - pta-test networks: - pta: + pta-test: driver: bridge volumes: - mariadb_data: + mariadb_data_test: diff --git a/docker/db/apply-migrations.sh b/docker/db/apply-migrations.sh new file mode 100755 index 0000000..2d9e8db --- /dev/null +++ b/docker/db/apply-migrations.sh @@ -0,0 +1,153 @@ +#!/bin/sh +# ============================================================================ +# PTA — application automatique des migrations SQL au démarrage de la stack +# ============================================================================ +# Exécuté par le service jetable `db-migrate` du docker-compose, une fois que +# MariaDB est déclaré sain. Le service `php` attend la fin de ce script en +# succès : si une migration échoue, l'application ne démarre pas du tout. +# +# Le schéma initial (database/pta_prod_schema_mariadb.sql) reste joué par +# docker-entrypoint-initdb.d à la création du volume. Ce script ne traite que +# les fichiers `migration_*.sql`, dans l'ordre alphabétique, sur une base déjà +# existante comme sur une base fraîchement créée. +# +# Un registre `schema_migration` mémorise chaque fichier appliqué avec son +# empreinte. Un fichier déjà appliqué et inchangé est ignoré ; un fichier +# modifié est rejoué — les migrations PTA sont écrites idempotentes +# (CREATE TABLE IF NOT EXISTS, INSERT ... ON DUPLICATE KEY UPDATE, +# CREATE OR REPLACE VIEW), les rejouer est sans effet de bord. +# ============================================================================ + +set -eu + +DB_HOST="${DB_HOST:-db}" +DB_PORT="${DB_PORT:-3306}" +DB_NAME="${DB_NAME:-pta}" +DB_USER="${DB_USER:-root}" +MIGRATIONS_DIR="${MIGRATIONS_DIR:-/migrations}" + +# Le mot de passe passe par MYSQL_PWD et jamais par la ligne de commande : +# un `ps` dans le conteneur ne doit pas le révéler. +MYSQL_PWD="${DB_PASSWORD:-}" +export MYSQL_PWD + +log() { + echo "[pta-migrate] $*" +} + +sql() { + # --ssl-verify-server-cert=0 : le client MariaDB 11.4 désactive de toute + # façon cette vérification quand le mot de passe ne vient pas de la ligne + # de commande, en émettant un avertissement trompeur parlant de « login + # sans mot de passe ». Le poser explicitement supprime ce bruit dans les + # journaux de déploiement sans rien changer au comportement réel : la + # liaison reste interne au réseau bridge du compose. + mariadb --host="$DB_HOST" --port="$DB_PORT" --user="$DB_USER" \ + --database="$DB_NAME" --default-character-set=utf8mb4 \ + --ssl-verify-server-cert=0 "$@" +} + +# ---------------------------------------------------------------------------- +# Attente de la base +# ---------------------------------------------------------------------------- +# Le compose garantit déjà `service_healthy`, mais le healthcheck interroge la +# boucle locale du conteneur : cette boucle couvre le court instant où le port +# n'accepte pas encore de connexion depuis le réseau. +attente=0 +until sql -N -B -e 'SELECT 1' >/dev/null 2>&1; do + attente=$((attente + 1)) + if [ "$attente" -ge 30 ]; then + log "ERREUR : base $DB_NAME injoignable sur $DB_HOST:$DB_PORT après 60 s." + exit 1 + fi + log "Base pas encore joignable, nouvelle tentative dans 2 s ($attente/30)…" + sleep 2 +done + +log "Base $DB_NAME joignable sur $DB_HOST:$DB_PORT." + +# ---------------------------------------------------------------------------- +# Registre des migrations appliquées +# ---------------------------------------------------------------------------- +sql <<'SQL' +CREATE TABLE IF NOT EXISTS schema_migration ( + fichier VARCHAR(190) NOT NULL, + empreinte CHAR(32) NOT NULL, + applique_le DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (fichier) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +SQL + +# ---------------------------------------------------------------------------- +# Application des migrations +# ---------------------------------------------------------------------------- +applique=0 +ignore=0 + +for fichier in "$MIGRATIONS_DIR"/migration_*.sql; do + # Le glob non résolu se présente tel quel quand aucun fichier ne correspond. + [ -f "$fichier" ] || continue + + nom=$(basename "$fichier") + empreinte=$(md5sum < "$fichier" | cut -d' ' -f1) + connue=$(sql -N -B -e \ + "SELECT empreinte FROM schema_migration WHERE fichier = '$nom'") + + if [ "$connue" = "$empreinte" ]; then + log "$nom : déjà appliquée, ignorée." + ignore=$((ignore + 1)) + continue + fi + + if [ -n "$connue" ]; then + log "$nom : contenu modifié depuis la dernière application, rejeu." + else + log "$nom : application…" + fi + + if ! sql < "$fichier"; then + log "ERREUR : échec de $nom. L'application ne sera pas démarrée." + exit 1 + fi + + sql -e "INSERT INTO schema_migration (fichier, empreinte) + VALUES ('$nom', '$empreinte') + ON DUPLICATE KEY UPDATE empreinte = VALUES(empreinte), + applique_le = CURRENT_TIMESTAMP" + + log "$nom : appliquée." + applique=$((applique + 1)) +done + +log "Bilan : $applique migration(s) appliquée(s), $ignore ignorée(s)." + +# ---------------------------------------------------------------------------- +# Contrôle final : les vues de droits doivent exister +# ---------------------------------------------------------------------------- +# Même contrôle que SchemaModel::assertAccessViews() côté PHP. Le faire ici +# évite de laisser démarrer une pile dont la page d'accueil renverrait une +# erreur 500 incompréhensible. +manquantes=$(sql -N -B -e " + SELECT GROUP_CONCAT(attendue.nom ORDER BY attendue.nom SEPARATOR ', ') + FROM ( + SELECT 'v_acces_role_page' AS nom + UNION ALL SELECT 'v_acces_role_permission' + UNION ALL SELECT 'v_acces_identite_agent' + UNION ALL SELECT 'v_acces_identite_structure' + UNION ALL SELECT 'v_acces_agent_par_structure' + UNION ALL SELECT 'v_acces_perimetre_creneau' + UNION ALL SELECT 'v_acces_perimetre_planning' + ) AS attendue + LEFT JOIN information_schema.VIEWS v + ON v.TABLE_SCHEMA = DATABASE() + AND v.TABLE_NAME = attendue.nom + WHERE v.TABLE_NAME IS NULL") + +if [ -n "$manquantes" ] && [ "$manquantes" != "NULL" ]; then + log "ERREUR : vue(s) de contrôle d'accès manquante(s) : $manquantes" + log "La migration v7.8 n'a pas produit le résultat attendu." + exit 1 +fi + +log "Les sept vues de contrôle d'accès sont présentes. Base à jour."