Rendre le déploiement possible : image de production, amorçage, langues
L'image de production existait mais n'était jamais partie. Quatre manques la
rendaient inexploitable, tous silencieux — l'application démarrait dans les
quatre cas.
Le front n'était pas dans l'image
public/build et public/index.html sont gitignorés, et le Dockerfile n'avait
pas d'étage Node. L'API répondait, chaque URL de l'interface renvoyait 503 :
une panne partielle, donc plus déroutante qu'une panne franche. Étage
app_frontend ajouté, avec une vérification au build qui échoue si le front
manque.
Aucune migration n'était appliquée
Seul l'entrypoint de dev le faisait. docker/entrypoint.prod.sh applique les
migrations sous garde RUN_MIGRATIONS et refuse de démarrer sur un APP_SECRET
vide, un DATABASE_URL absent ou un BACK_OFFICE_URL sur localhost — cette
dernière valeur ne casse rien de visible, elle casse la réception des
invitations, ailleurs, plusieurs jours plus tard.
Aucun moyen de créer le premier compte
Les comptes s'obtiennent par invitation, et inviter demande d'être connecté.
Les fixtures sont une dépendance de dev. Il ne restait que l'INSERT à la
main. app:create-super-admin crée l'organisation et le premier compte, refuse
une adresse connue, exige douze caractères.
Le référentiel de langues était vide
Créé par les fixtures, donc absent en production. Sans langue source à
choisir, aucun projet ne pouvait être créé. Passé en migration : c'est une
donnée de référence, pas un jeu d'exemple.
Au passage : l'expéditeur des courriels était en dur sur no-reply@tq-slator.local,
qu'un relais SMTP réel rejette. Devenu MAILER_FROM.
compose.prod.yaml est un fichier distinct, et c'est le point qui compte : Docker
fusionne compose.override.yaml dès que le principal s'appelle compose.yaml, si
bien que Mailpit et les ports publiés seraient partis en production sans que
personne ne l'ait demandé.
Parcours vérifié sur une pile neuve : connexion, création de projet, plateforme,
push de clés, publication, clé API, fichier livré.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
9025c64c0b
commit
ad104c82d7
10 changed files with 699 additions and 2 deletions
54
.dockerignore
Normal file
54
.dockerignore
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Ce que le contexte de build ne doit PAS emporter.
|
||||
#
|
||||
# Absent jusqu'ici parce que l'image de dev monte les sources en volume et ne
|
||||
# copie rien. L'étage de production, lui, fait `COPY . ./` : sans ce fichier,
|
||||
# il embarquait node_modules, vendor, l'historique Git et les caches locaux —
|
||||
# plusieurs centaines de mégaoctets, et surtout le risque d'écraser dans
|
||||
# l'image un vendor/ construit pour une autre plateforme que la cible.
|
||||
|
||||
# ─── Dépendances : reconstruites dans leurs étages dédiés ───────────────────
|
||||
/vendor/
|
||||
/node_modules/
|
||||
|
||||
# ─── Sorties de build : reconstruites, jamais reprises de l'hôte ────────────
|
||||
# Reprendre le public/build de la machine du développeur ferait dépendre le
|
||||
# contenu de l'image de ce qu'il avait lancé la veille.
|
||||
/public/build/
|
||||
/public/index.html
|
||||
/build/
|
||||
/.vite/
|
||||
|
||||
# ─── État local ────────────────────────────────────────────────────────────
|
||||
/var/
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/.phpstan.cache/
|
||||
/.phpunit.cache/
|
||||
/phpunit.xml
|
||||
/phpstan.neon.local
|
||||
/.php-cs-fixer.cache
|
||||
|
||||
# ─── Historique et outillage ───────────────────────────────────────────────
|
||||
/.git/
|
||||
/.gitignore
|
||||
/.github/
|
||||
/.gstack/
|
||||
/.idea/
|
||||
/.vscode/
|
||||
.DS_Store
|
||||
|
||||
# ─── Ce qui ne sert qu'au développement ────────────────────────────────────
|
||||
#
|
||||
# `docker/` n'est PAS exclu : l'étage dev y copie son entrypoint et l'étage
|
||||
# prod le sien. Les exclure a cassé la construction de l'image de dev, avec un
|
||||
# message peu parlant — « failed to calculate checksum: not found » — parce que
|
||||
# Docker ne dit pas qu'un fichier a été ignoré, seulement qu'il est absent.
|
||||
/compose.yaml
|
||||
/compose.override.yaml
|
||||
/compose.prod.yaml
|
||||
/tests/
|
||||
/phpunit.dist.xml
|
||||
/phpstan.neon
|
||||
/.php-cs-fixer.dist.php
|
||||
/.editorconfig
|
||||
3
.env
3
.env
|
|
@ -33,6 +33,9 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
|||
###> symfony/mailer ###
|
||||
# Mailpit en local : toutes les invitations sont capturées sur http://localhost:8025
|
||||
MAILER_DSN=smtp://mailer:1025
|
||||
# Expéditeur des invitations. En production, un domaine réellement délivrable :
|
||||
# un « .local » est rejeté par les relais SMTP, ou classé indésirable.
|
||||
MAILER_FROM=no-reply@tq-slator.local
|
||||
###< symfony/mailer ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
|
|
|
|||
44
Dockerfile
44
Dockerfile
|
|
@ -5,6 +5,7 @@
|
|||
# en 8.5 sans que cela change ce qui sera déployé.
|
||||
ARG PHP_VERSION=8.4
|
||||
ARG FRANKENPHP_VERSION=1
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# base — runtime commun dev/prod
|
||||
|
|
@ -77,6 +78,30 @@ COPY --link composer.json composer.lock symfony.lock ./
|
|||
RUN --mount=type=cache,target=/tmp/composer \
|
||||
composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# frontend — construction du back-office React
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Indispensable, et pas une commodité : `public/build/` et `public/index.html`
|
||||
# sont dans .gitignore. Une image construite depuis le dépôt sans cet étage
|
||||
# démarre sans back-office — SpaController répond alors 503 « Le back-office
|
||||
# n'est pas construit » sur chaque URL de l'interface. L'API, elle, fonctionne :
|
||||
# la panne est donc partielle, ce qui la rend plus déroutante encore.
|
||||
FROM node:${NODE_VERSION}-alpine AS app_frontend
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Les manifestes d'abord : cette couche ne se reconstruit que lorsque les
|
||||
# dépendances changent, pas à chaque modification d'un composant.
|
||||
COPY --link package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY --link tsconfig.json vite.config.ts ./
|
||||
COPY --link assets ./assets
|
||||
|
||||
# `npm run build` lance `tsc --noEmit && vite build` : une erreur de typage
|
||||
# casse donc le déploiement ici, et non en production.
|
||||
RUN npm run build
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# prod — image finale, worker mode activé
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -92,13 +117,30 @@ COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
|
|||
COPY --from=app_vendor --link /app/vendor ./vendor
|
||||
COPY --link . ./
|
||||
|
||||
# Le front APRÈS les sources : `COPY . ./` écraserait sinon public/ avec la
|
||||
# version du dépôt, qui ne contient que index.php.
|
||||
COPY --from=app_frontend --link /build/public/index.html ./public/index.html
|
||||
COPY --from=app_frontend --link /build/public/build ./public/build
|
||||
|
||||
RUN set -eux; \
|
||||
mkdir -p var/cache var/log var/storage; \
|
||||
composer dump-autoload --classmap-authoritative --no-dev; \
|
||||
composer dump-env prod; \
|
||||
composer run-script --no-dev post-install-cmd; \
|
||||
chmod +x bin/console; \
|
||||
chmod +x bin/console docker/entrypoint.prod.sh; \
|
||||
setfacl -R -m u:www-data:rwX -m u:root:rwX var; \
|
||||
setfacl -dR -m u:www-data:rwX -m u:root:rwX var
|
||||
|
||||
# Vérification au BUILD que le back-office est bien dans l'image. Le contrôleur
|
||||
# dégrade proprement en 503 s'il manque — ce qui est le bon comportement en
|
||||
# exécution, mais transformerait ici une erreur de build en panne silencieuse
|
||||
# découverte par le premier utilisateur.
|
||||
RUN test -f public/index.html && test -d public/build \
|
||||
|| (echo 'Le front est absent de l’image : étage app_frontend cassé.' >&2 && exit 1)
|
||||
|
||||
# CMD est redéclaré parce que Docker le remet à zéro dès qu'un étage redéfinit
|
||||
# ENTRYPOINT — le même piège que dans l'étage dev.
|
||||
ENTRYPOINT ["docker-php-entrypoint", "docker/entrypoint.prod.sh"]
|
||||
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]
|
||||
|
||||
VOLUME /app/var/storage
|
||||
|
|
|
|||
101
README.md
101
README.md
|
|
@ -387,6 +387,107 @@ redistribué dans chaque clé i18next (rendu identique, structure différente),
|
|||
le type d'un argument ICU est perdu (le nom survit). Voir
|
||||
`docs/01-architecture-proposal.md` §4.3.
|
||||
|
||||
## Déploiement
|
||||
|
||||
La pile de développement (`compose.yaml`) ne part **pas** en production : elle
|
||||
cible l'image `app_dev`, monte les sources en volume, publie des ports et
|
||||
branche Mailpit. `compose.override.yaml`, que Docker fusionne automatiquement,
|
||||
en rajouterait. La production a donc son propre fichier :
|
||||
|
||||
```bash
|
||||
docker compose -f compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
### Ce que l'image de production contient
|
||||
|
||||
Le back-office React est construit **dans l'image**, par un étage Node dédié.
|
||||
C'est indispensable et non une commodité : `public/build/` et
|
||||
`public/index.html` sont dans `.gitignore`. Sans cet étage, l'application
|
||||
démarre, l'API répond, et chaque URL de l'interface renvoie 503 « Le back-office
|
||||
n'est pas construit » — une panne partielle, donc plus déroutante qu'une panne
|
||||
franche. Une vérification au build échoue si le front manque.
|
||||
|
||||
`npm run build` lance `tsc --noEmit` avant Vite : une erreur de typage casse le
|
||||
déploiement, pas la production.
|
||||
|
||||
### Variables à renseigner
|
||||
|
||||
Quatre sont obligatoires. Absentes, le déploiement s'arrête au lieu de démarrer
|
||||
un service à moitié configuré.
|
||||
|
||||
| Variable | Rôle |
|
||||
|---|---|
|
||||
| `APP_SECRET` | Signature des cookies de session. `openssl rand -hex 32` |
|
||||
| `MARIADB_PASSWORD` | Mot de passe applicatif MariaDB |
|
||||
| `MARIADB_ROOT_PASSWORD` | Mot de passe root MariaDB |
|
||||
| `BACK_OFFICE_URL` | URL **publique**, `https://…`. Sert aux liens d'invitation |
|
||||
| `MAILER_DSN` | Relais SMTP réel. Mailpit ne part pas en production |
|
||||
|
||||
Facultatives : `MAILER_FROM` (expéditeur, défaut `no-reply@tranquilys.com`),
|
||||
`DEFAULT_ORGANIZATION_SLUG`, `RELEASE_RETENTION_UNREFERENCED`,
|
||||
`SYMFONY_TRUSTED_PROXIES`, `CORS_ALLOW_ORIGIN`, `INVITATION_TTL`.
|
||||
|
||||
`BACK_OFFICE_URL` sur `localhost` est **refusé au démarrage**. Une valeur fausse
|
||||
ne casse rien de visible : elle casse la réception des invitations, ailleurs,
|
||||
plusieurs jours plus tard.
|
||||
|
||||
### Premier démarrage
|
||||
|
||||
Les comptes s'obtiennent par invitation, et inviter demande d'être connecté.
|
||||
Une installation neuve a donc besoin d'un amorçage :
|
||||
|
||||
```bash
|
||||
docker compose -f compose.prod.yaml exec php \
|
||||
php bin/console app:create-super-admin vous@exemple.fr "Votre Nom"
|
||||
```
|
||||
|
||||
Le mot de passe est demandé sans être affiché — il ne reste donc pas dans
|
||||
l'historique du shell. Douze caractères minimum, comme à l'acceptation d'une
|
||||
invitation : un premier compte plus faible que les suivants serait le maillon
|
||||
par lequel on entre.
|
||||
|
||||
La commande refuse une adresse déjà connue : elle deviendrait sinon un moyen de
|
||||
s'attribuer le compte d'un autre.
|
||||
|
||||
Le référentiel de langues est installé par une **migration**, pas par les
|
||||
fixtures — c'est une donnée de référence, identique pour tout le monde, et sans
|
||||
elle aucun projet ne peut être créé faute de langue source à choisir.
|
||||
|
||||
### Déployer via Coolify
|
||||
|
||||
1. **Nouvelle ressource → Docker Compose**, dépôt
|
||||
`ssh://git@git.tranquilys.com:22222/Stephan/TQ-Slator.git`, branche `main`.
|
||||
2. Fichier compose : `compose.prod.yaml`. Ne pas laisser `compose.yaml`, que
|
||||
Coolify propose par défaut.
|
||||
3. Renseigner les variables du tableau ci-dessus dans l'onglet *Environment*.
|
||||
4. Attribuer le domaine au service **`php`**. `SERVICE_FQDN_PHP_80` est déjà
|
||||
déclaré dans le fichier : Coolify y place le domaine et configure son proxy.
|
||||
5. Déployer, puis lancer la commande d'amorçage ci-dessus depuis le terminal de
|
||||
la ressource.
|
||||
|
||||
Le TLS est terminé par le proxy de Coolify ; Caddy a donc `auto_https off`, et
|
||||
`SYMFONY_TRUSTED_PROXIES` est renseigné pour que Symfony lise
|
||||
`X-Forwarded-Proto` — sans quoi les liens d'invitation partiraient en `http://`
|
||||
depuis un site en `https://`.
|
||||
|
||||
Aucun port n'est publié sur l'hôte : le proxy atteint les conteneurs par le
|
||||
réseau interne. Sur une VM sans Coolify, retirer `SERVICE_FQDN_PHP_80` et
|
||||
ajouter `ports: ['80:80']` au service `php`.
|
||||
|
||||
### Ce que le déploiement fait tout seul
|
||||
|
||||
- **Migrations** — appliquées au démarrage par le service `php`, et par lui
|
||||
seul. Le `worker` attend que le schéma soit à jour avant de consommer, sinon
|
||||
il polluerait les journaux du premier déploiement avec des erreurs qui n'en
|
||||
sont pas.
|
||||
- **Cache applicatif** — figé au build, jamais régénéré au démarrage : deux
|
||||
conteneurs issus de la même image doivent être identiques.
|
||||
|
||||
### Volumes à conserver
|
||||
|
||||
`db_data` et `app_storage`. Le second porte les captures d'écran de contexte et
|
||||
les pièces jointes — le seul état applicatif hors base.
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
|
|
|
|||
160
compose.prod.yaml
Normal file
160
compose.prod.yaml
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Pile de production — destinée à Coolify, utilisable telle quelle sur une VM.
|
||||
#
|
||||
# Fichier DISTINCT de compose.yaml, et c'est le point qui compte le plus ici :
|
||||
# `docker compose` fusionne automatiquement compose.override.yaml dès que le
|
||||
# fichier principal s'appelle compose.yaml. L'override publie des ports et
|
||||
# branche Mailpit ; il partirait donc en production sans que personne ne l'ait
|
||||
# demandé. Un nom explicite ferme la porte.
|
||||
#
|
||||
# Trois différences de fond avec la pile de développement :
|
||||
# - cible d'image `app_prod` (worker mode FrankenPHP, cache figé au build),
|
||||
# et non `app_dev` ;
|
||||
# - aucune source montée : ce qui tourne est ce qui a été construit ;
|
||||
# - aucun port publié sur l'hôte — le proxy de Coolify atteint les conteneurs
|
||||
# par le réseau interne. Publier 8080 exposerait l'application en clair, à
|
||||
# côté du HTTPS, ce qui annulerait ce que le proxy vient de faire.
|
||||
|
||||
services:
|
||||
php:
|
||||
build:
|
||||
context: .
|
||||
target: app_prod
|
||||
environment:
|
||||
# Coolify remplace cette variable par le domaine attribué au service et
|
||||
# configure son proxy en conséquence. Sur une VM sans Coolify, retirez
|
||||
# cette ligne et ajoutez `ports: ['80:80']`.
|
||||
SERVICE_FQDN_PHP_80: '/'
|
||||
|
||||
SERVER_NAME: ':80'
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: '0'
|
||||
|
||||
# Le TLS est terminé par le proxy. Laisser Caddy tenter d'obtenir un
|
||||
# certificat produirait des échecs ACME en boucle sur un domaine qu'il ne
|
||||
# contrôle pas.
|
||||
CADDY_GLOBAL_OPTIONS: 'auto_https off'
|
||||
|
||||
# Le proxy est le seul à parler au conteneur : sans cette liste, Symfony
|
||||
# ignore X-Forwarded-Proto et fabrique des URL en http:// derrière un
|
||||
# site en https:// — les liens d'invitation partiraient en clair.
|
||||
# Nom imposé par Symfony (framework.trusted_proxies le lit d'office) ;
|
||||
# « TRUSTED_PROXIES » tout court ne serait lu par personne.
|
||||
SYMFONY_TRUSTED_PROXIES: '${SYMFONY_TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR}'
|
||||
# Le back-office est servi en même origine que l'API : le navigateur
|
||||
# n'émet donc aucune requête cross-origin pour lui. Cette valeur ne
|
||||
# concerne que la Delivery API appelée depuis un autre domaine.
|
||||
CORS_ALLOW_ORIGIN: '${CORS_ALLOW_ORIGIN:-^$$}'
|
||||
|
||||
# `?` : absente, la variable arrête le déploiement au lieu de laisser
|
||||
# démarrer un service à moitié configuré.
|
||||
APP_SECRET: '${APP_SECRET:?openssl rand -hex 32}'
|
||||
DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD:?mot de passe MariaDB}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
|
||||
REDIS_URL: 'redis://cache:6379'
|
||||
|
||||
# Sert à composer les liens d'invitation envoyés par e-mail. Une valeur
|
||||
# fausse ne casse rien de visible ici : elle casse la réception, ailleurs,
|
||||
# plus tard. L'entrypoint refuse donc localhost.
|
||||
BACK_OFFICE_URL: '${BACK_OFFICE_URL:?URL publique du back-office}'
|
||||
MAILER_DSN: '${MAILER_DSN:?DSN SMTP réel, Mailpit ne part pas en production}'
|
||||
MAILER_FROM: '${MAILER_FROM:-no-reply@tranquilys.com}'
|
||||
|
||||
DEFAULT_ORGANIZATION_SLUG: '${DEFAULT_ORGANIZATION_SLUG:-tranquilys}'
|
||||
RELEASE_RETENTION_UNREFERENCED: '${RELEASE_RETENTION_UNREFERENCED:-5}'
|
||||
INVITATION_TTL: '${INVITATION_TTL:-P7D}'
|
||||
INVITATION_TTL_EXTERNAL: '${INVITATION_TTL_EXTERNAL:-P2D}'
|
||||
|
||||
# Ce service est le SEUL à migrer. Voir docker/entrypoint.prod.sh.
|
||||
RUN_MIGRATIONS: '1'
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
cache:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# Captures d'écran de contexte et pièces jointes. Le seul état applicatif
|
||||
# hors base : sans ce volume, un redéploiement les efface.
|
||||
- app_storage:/app/var/storage
|
||||
restart: unless-stopped
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
target: app_prod
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: '0'
|
||||
APP_SECRET: '${APP_SECRET:?openssl rand -hex 32}'
|
||||
DATABASE_URL: 'mysql://tqslator:${MARIADB_PASSWORD:?mot de passe MariaDB}@database:3306/tqslator?serverVersion=11.4.0-MariaDB&charset=utf8mb4'
|
||||
REDIS_URL: 'redis://cache:6379'
|
||||
BACK_OFFICE_URL: '${BACK_OFFICE_URL:?URL publique du back-office}'
|
||||
MAILER_DSN: '${MAILER_DSN:?DSN SMTP réel}'
|
||||
MAILER_FROM: '${MAILER_FROM:-no-reply@tranquilys.com}'
|
||||
DEFAULT_ORGANIZATION_SLUG: '${DEFAULT_ORGANIZATION_SLUG:-tranquilys}'
|
||||
# Ne migre pas : il ATTEND que le schéma soit à jour.
|
||||
RUN_MIGRATIONS: '0'
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
# Attend que `php` ait fini de migrer. Sans cela le consommateur démarre
|
||||
# sur une base sans table messenger_messages, échoue, redémarre — il
|
||||
# finirait par s'en sortir, mais en polluant les journaux du premier
|
||||
# déploiement avec des erreurs qui ne sont pas des erreurs.
|
||||
php:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- app_storage:/app/var/storage
|
||||
# `--time-limit` fait sortir le processus périodiquement : c'est le moyen
|
||||
# standard de rendre à l'OS la mémoire qu'un worker PHP finit par retenir.
|
||||
# `restart` le relance aussitôt.
|
||||
command: ['php', 'bin/console', 'messenger:consume', 'async', '--time-limit=3600', '--memory-limit=192M', '-v']
|
||||
# Le healthcheck hérité interroge l'endpoint admin de Caddy, que ce service
|
||||
# ne fait pas tourner. Le consommateur est PID 1 : s'il meurt, le conteneur
|
||||
# sort et Docker le relance. Une sonde ne ferait que dupliquer cela.
|
||||
healthcheck:
|
||||
disable: true
|
||||
restart: unless-stopped
|
||||
|
||||
database:
|
||||
image: mariadb:11.4
|
||||
environment:
|
||||
MARIADB_DATABASE: tqslator
|
||||
MARIADB_USER: tqslator
|
||||
MARIADB_PASSWORD: '${MARIADB_PASSWORD:?mot de passe MariaDB}'
|
||||
MARIADB_ROOT_PASSWORD: '${MARIADB_ROOT_PASSWORD:?mot de passe root MariaDB}'
|
||||
command:
|
||||
# Doit correspondre à default_table_options de doctrine.yaml, sinon les
|
||||
# tables créées hors migration divergent en silence. Voir §3.6 de
|
||||
# docs/01-architecture-proposal.md.
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_uca1400_ai_ci
|
||||
- --innodb-default-row-format=dynamic
|
||||
# Pas de log des requêtes sans index en production : à ce volume il
|
||||
# remplirait le disque sans rien apprendre qu'on ne sache déjà.
|
||||
- --slow-query-log=1
|
||||
- --long-query-time=1
|
||||
healthcheck:
|
||||
test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 60s
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
|
||||
cache:
|
||||
image: redis:7-alpine
|
||||
# Ni RDB ni AOF : ce Redis ne porte que du cache et du verrouillage de
|
||||
# connexion. Le persister donnerait l'illusion d'une donnée qu'on peut
|
||||
# perdre sans conséquence — et qu'on perdra donc mal.
|
||||
command: ['redis-server', '--save', '', '--appendonly', 'no', '--maxmemory', '256mb', '--maxmemory-policy', 'allkeys-lru']
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
app_storage:
|
||||
|
|
@ -12,6 +12,10 @@ parameters:
|
|||
# peut pas être déduite d'une requête : les courriels partent depuis un
|
||||
# worker, hors contexte HTTP.
|
||||
app.back_office_url: '%env(BACK_OFFICE_URL)%'
|
||||
# Expéditeur des courriels sortants. Paramétrable parce qu'un domaine
|
||||
# en dur ne survit pas au premier relais SMTP réel : un `.local` est
|
||||
# rejeté, ou classé indésirable, selon l'humeur du destinataire.
|
||||
app.mailer_from: '%env(MAILER_FROM)%'
|
||||
|
||||
app.invitation_ttl: '%env(INVITATION_TTL)%'
|
||||
app.invitation_ttl_external: '%env(INVITATION_TTL_EXTERNAL)%'
|
||||
|
|
@ -30,6 +34,7 @@ services:
|
|||
string $invitationTtl: '%app.invitation_ttl%'
|
||||
string $invitationTtlExternal: '%app.invitation_ttl_external%'
|
||||
string $backOfficeUrl: '%app.back_office_url%'
|
||||
string $mailerFrom: '%app.mailer_from%'
|
||||
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
|
|
|||
92
docker/entrypoint.prod.sh
Executable file
92
docker/entrypoint.prod.sh
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Démarrage d'un conteneur de production.
|
||||
#
|
||||
# Distinct de l'entrypoint de dev, et non une variante paramétrée : celui-ci ne
|
||||
# doit JAMAIS lancer `composer install` ni régénérer le cache applicatif. Tout
|
||||
# cela est figé dans l'image au build, et le refaire au démarrage rendrait deux
|
||||
# conteneurs issus de la même image potentiellement différents — exactement ce
|
||||
# qu'un déploiement reproductible cherche à éviter.
|
||||
#
|
||||
# Il ne reste donc que ce qui dépend de l'environnement d'exécution : la base.
|
||||
|
||||
# ── Garde-fous de configuration ───────────────────────────────────────────────
|
||||
#
|
||||
# Échouer ici, bruyamment, plutôt que démarrer un service à moitié configuré.
|
||||
# Une application qui répond avec un APP_SECRET vide est une application dont
|
||||
# les cookies de session sont forgeables ; mieux vaut qu'elle ne réponde pas.
|
||||
|
||||
if [ -z "${APP_SECRET:-}" ]; then
|
||||
echo '[entrypoint] APP_SECRET est vide. Générez-le une fois pour toutes :' >&2
|
||||
echo '[entrypoint] openssl rand -hex 32' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo '[entrypoint] DATABASE_URL est absent.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# BACK_OFFICE_URL sert à composer les liens d'invitation. Laissé sur sa valeur
|
||||
# de développement, il enverrait des e-mails pointant vers localhost — une
|
||||
# panne qui ne se voit que du côté du destinataire, plusieurs jours plus tard.
|
||||
case "${BACK_OFFICE_URL:-}" in
|
||||
''|*localhost*|*127.0.0.1*)
|
||||
echo "[entrypoint] BACK_OFFICE_URL vaut « ${BACK_OFFICE_URL:-vide} »." >&2
|
||||
echo '[entrypoint] Les liens d’invitation envoyés par e-mail seraient inutilisables.' >&2
|
||||
echo '[entrypoint] Renseignez l’URL publique du back-office.' >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p var/cache var/log var/storage
|
||||
|
||||
# ── Attente de la base ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Le healthcheck du service MariaDB couvre la disponibilité du serveur ; il ne
|
||||
# dit rien de la capacité de CETTE application à s'y connecter avec CES
|
||||
# identifiants. La distinction compte au premier déploiement, quand un mot de
|
||||
# passe erroné produirait sinon une boucle de redémarrage sans message clair.
|
||||
|
||||
attempts=0
|
||||
until php bin/console dbal:run-sql 'SELECT 1' >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
|
||||
if [ "$attempts" -ge 30 ]; then
|
||||
echo '[entrypoint] Base injoignable après 60 s. Dernière erreur :' >&2
|
||||
php bin/console dbal:run-sql 'SELECT 1' >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[entrypoint] attente de la base ($attempts/30)…"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# ── Migrations ────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Un seul service migre. Sans ce garde-fou, `php` et `worker` démarrent en
|
||||
# parallèle et se disputent la table de verrouillage des migrations.
|
||||
|
||||
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
|
||||
echo '[entrypoint] migrations…'
|
||||
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||
else
|
||||
attempts=0
|
||||
until php bin/console doctrine:migrations:up-to-date >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
|
||||
if [ "$attempts" -ge 60 ]; then
|
||||
echo '[entrypoint] Schéma toujours pas à jour après 120 s.' >&2
|
||||
echo '[entrypoint] Le service qui porte RUN_MIGRATIONS=1 a-t-il démarré ?' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[entrypoint] attente des migrations ($attempts/60)…"
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
echo '[entrypoint] prêt.'
|
||||
|
||||
exec "$@"
|
||||
106
migrations/Version20260821090000.php
Normal file
106
migrations/Version20260821090000.php
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Référentiel de langues.
|
||||
*
|
||||
* Dans une migration et non dans les fixtures, parce que ce n'est pas une
|
||||
* donnée d'exemple : « français (France) » a les mêmes catégories plurielles
|
||||
* pour tout le monde, et l'entité Locale est explicitement exemptée de
|
||||
* l'isolation multi-organisation à ce titre (voir TenantIsolationTest).
|
||||
*
|
||||
* Le manque se voyait mal : une installation neuve démarrait, se laissait
|
||||
* ouvrir, et n'échouait qu'au moment de créer un projet — sans langue source à
|
||||
* proposer, le formulaire n'avait aucune option. Les fixtures masquaient le
|
||||
* problème en développement, où elles peuplaient la table au passage.
|
||||
*
|
||||
* Les catégories sont celles du CLDR. Elles pilotent le nombre de champs que
|
||||
* l'éditeur présente pour un pluriel : deux en anglais, trois en français,
|
||||
* six en arabe. Se tromper ici produit une traduction incomplète que rien
|
||||
* d'autre ne rattrape.
|
||||
*
|
||||
* INSERT IGNORE : la migration doit pouvoir se rejouer sur une base où les
|
||||
* fixtures ont déjà inséré ces lignes, ce qui est le cas de toutes les bases
|
||||
* de développement existantes.
|
||||
*/
|
||||
final class Version20260821090000 extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* @var list<array{0: string, 1: string, 2: string, 3: string, 4: list<string>}>
|
||||
*/
|
||||
private const LOCALES = [
|
||||
['fr', 'French', 'Français', 'ltr', ['one', 'many', 'other']],
|
||||
['fr-FR', 'French (France)', 'Français (France)', 'ltr', ['one', 'many', 'other']],
|
||||
['fr-CA', 'French (Canada)', 'Français (Canada)', 'ltr', ['one', 'many', 'other']],
|
||||
['en', 'English', 'English', 'ltr', ['one', 'other']],
|
||||
['en-GB', 'English (UK)', 'English (UK)', 'ltr', ['one', 'other']],
|
||||
['en-US', 'English (US)', 'English (US)', 'ltr', ['one', 'other']],
|
||||
['es-ES', 'Spanish (Spain)', 'Español (España)', 'ltr', ['one', 'many', 'other']],
|
||||
['de-DE', 'German (Germany)', 'Deutsch (Deutschland)', 'ltr', ['one', 'other']],
|
||||
['it-IT', 'Italian (Italy)', 'Italiano (Italia)', 'ltr', ['one', 'many', 'other']],
|
||||
['pt-BR', 'Portuguese (Brazil)', 'Português (Brasil)', 'ltr', ['one', 'many', 'other']],
|
||||
['pt-PT', 'Portuguese (Portugal)', 'Português (Portugal)', 'ltr', ['one', 'many', 'other']],
|
||||
['nl-NL', 'Dutch (Netherlands)', 'Nederlands', 'ltr', ['one', 'other']],
|
||||
// L'arabe est présent expressément : six formes et sens d'écriture
|
||||
// inversé, c'est la langue qui casse les éditeurs mal conçus.
|
||||
['ar', 'Arabic', 'العربية', 'rtl', ['zero', 'one', 'two', 'few', 'many', 'other']],
|
||||
['he', 'Hebrew', 'עברית', 'rtl', ['one', 'two', 'many', 'other']],
|
||||
['pl-PL', 'Polish (Poland)', 'Polski', 'ltr', ['one', 'few', 'many', 'other']],
|
||||
['ru-RU', 'Russian (Russia)', 'Русский', 'ltr', ['one', 'few', 'many', 'other']],
|
||||
['ja-JP', 'Japanese (Japan)', '日本語', 'ltr', ['other']],
|
||||
['zh-CN', 'Chinese (Simplified)', '简体中文', 'ltr', ['other']],
|
||||
];
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Installe le référentiel de langues : sans lui, aucun projet ne peut être créé.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
foreach (self::LOCALES as [$code, $name, $nativeName, $direction, $plurals]) {
|
||||
$this->addSql(
|
||||
'INSERT IGNORE INTO locale (code, name, native_name, direction, plural_categories) '
|
||||
.'VALUES (:code, :name, :nativeName, :direction, :plurals)',
|
||||
[
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
'nativeName' => $nativeName,
|
||||
'direction' => $direction,
|
||||
'plurals' => json_encode($plurals, \JSON_THROW_ON_ERROR),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Chaînes de repli : une traduction manquante en fr-CA retombe sur fr
|
||||
// avant de retomber sur la langue source du projet.
|
||||
foreach ([['fr-FR', 'fr'], ['fr-CA', 'fr'], ['en-GB', 'en'], ['en-US', 'en'], ['pt-PT', 'pt-BR']] as [$child, $parent]) {
|
||||
$this->addSql(
|
||||
'UPDATE locale c JOIN locale p ON p.code = :parent SET c.fallback_locale_id = p.id '
|
||||
.'WHERE c.code = :child AND c.fallback_locale_id IS NULL',
|
||||
['child' => $child, 'parent' => $parent],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// On ne supprime que les langues qu'AUCUN projet n'utilise. Effacer une
|
||||
// langue référencée casserait les traductions existantes en cascade, ce
|
||||
// qu'un retour en arrière de migration ne doit jamais faire.
|
||||
$this->addSql('UPDATE locale SET fallback_locale_id = NULL');
|
||||
$this->addSql(
|
||||
'DELETE FROM locale WHERE code IN (:codes) '
|
||||
.'AND id NOT IN (SELECT locale_id FROM project_locale) '
|
||||
.'AND id NOT IN (SELECT source_locale_id FROM project)',
|
||||
['codes' => array_column(self::LOCALES, 0)],
|
||||
['codes' => \Doctrine\DBAL\ArrayParameterType::STRING],
|
||||
);
|
||||
}
|
||||
}
|
||||
133
src/Command/CreateSuperAdminCommand.php
Normal file
133
src/Command/CreateSuperAdminCommand.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Organization;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* Crée le premier compte d'une installation.
|
||||
*
|
||||
* Sans elle, une installation neuve est inutilisable : les comptes s'obtiennent
|
||||
* par invitation, et inviter demande d'être déjà connecté. Les fixtures, qui
|
||||
* amorçaient le développement, sont une dépendance `require-dev` — absentes de
|
||||
* l'image de production. Il ne restait donc que l'INSERT à la main.
|
||||
*
|
||||
* Volontairement minimale : elle crée l'organisation si elle manque et un
|
||||
* super-administrateur. Le reste — projets, langues, plateformes — se fait
|
||||
* depuis l'interface, qui sait le faire correctement.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:create-super-admin',
|
||||
description: 'Crée l\'organisation et le premier super-administrateur.',
|
||||
)]
|
||||
final class CreateSuperAdminCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Le minimum imposé à l'acceptation d'une invitation. Un premier compte
|
||||
* plus faible que les suivants serait le maillon par lequel on entre.
|
||||
*/
|
||||
private const MIN_PASSWORD_LENGTH = 12;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserRepository $users,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly string $defaultOrganizationSlug,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('email', InputArgument::REQUIRED, 'Adresse de connexion')
|
||||
->addArgument('name', InputArgument::OPTIONAL, 'Nom affiché')
|
||||
->addOption(
|
||||
'password',
|
||||
'p',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Mot de passe. Omis, il est demandé sans être affiché — '
|
||||
.'ce qui évite de le laisser dans l\'historique du shell.',
|
||||
)
|
||||
->addOption('organization', null, InputOption::VALUE_REQUIRED, 'Nom de l\'organisation à créer si elle n\'existe pas');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$email = trim((string) $input->getArgument('email'));
|
||||
|
||||
if (!filter_var($email, \FILTER_VALIDATE_EMAIL)) {
|
||||
$io->error(sprintf('« %s » n\'est pas une adresse valide.', $email));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
// Le filtre multi-organisation n'est pas actif hors contexte HTTP, la
|
||||
// recherche porte donc bien sur toute la base.
|
||||
if (null !== $this->users->findOneBy(['email' => $email])) {
|
||||
$io->error(sprintf(
|
||||
'Un compte existe déjà pour %s. Cette commande ne modifie jamais un compte existant : '
|
||||
.'elle deviendrait sinon un moyen de s\'attribuer celui d\'un autre.',
|
||||
$email,
|
||||
));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$password = $input->getOption('password');
|
||||
|
||||
if (!\is_string($password) || '' === $password) {
|
||||
$question = (new Question('Mot de passe : '))->setHidden(true)->setHiddenFallback(false);
|
||||
$password = (string) $io->askQuestion($question);
|
||||
}
|
||||
|
||||
if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) {
|
||||
$io->error(sprintf('Le mot de passe doit faire au moins %d caractères.', self::MIN_PASSWORD_LENGTH));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$organization = $this->entityManager
|
||||
->getRepository(Organization::class)
|
||||
->findOneBy(['slug' => $this->defaultOrganizationSlug]);
|
||||
|
||||
if (null === $organization) {
|
||||
$name = (string) ($input->getOption('organization') ?? ucfirst($this->defaultOrganizationSlug));
|
||||
$organization = new Organization($name, $this->defaultOrganizationSlug);
|
||||
$this->entityManager->persist($organization);
|
||||
|
||||
$io->text(sprintf('Organisation « %s » créée (slug : %s).', $name, $this->defaultOrganizationSlug));
|
||||
}
|
||||
|
||||
$user = new User($organization, $email, trim((string) ($input->getArgument('name') ?? $email)));
|
||||
$user->setRoles([User::ROLE_SUPER_ADMIN]);
|
||||
$user->setPassword($this->passwordHasher->hashPassword($user, $password));
|
||||
|
||||
$this->entityManager->persist($user);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$io->success(sprintf('Super-administrateur %s créé.', $email));
|
||||
$io->text([
|
||||
'Prochaine étape : se connecter, puis créer un projet depuis « Administration ».',
|
||||
'Les comptes suivants s\'obtiennent par invitation — cette commande ne sert qu\'une fois.',
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ final readonly class SendInvitationEmailHandler
|
|||
private InvitationRepository $invitations,
|
||||
private MailerInterface $mailer,
|
||||
private string $backOfficeUrl,
|
||||
private string $mailerFrom,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ final readonly class SendInvitationEmailHandler
|
|||
$expiresIn = max(1, (int) ceil($remaining / 86400));
|
||||
|
||||
$email = (new Email())
|
||||
->from('no-reply@tq-slator.local')
|
||||
->from($this->mailerFrom)
|
||||
->to($invitation->getEmail())
|
||||
->subject(sprintf(
|
||||
'Invitation à traduire %s',
|
||||
|
|
|
|||
Loading…
Reference in a new issue