**Le test des heures ne pouvait que tomber.** Il interrogeait le mois précédent, alors que le seed ne pose que deux semaines : la courante et la précédente. Hors des premiers jours d'un mois, le mois précédent est donc vide. Mesuré sur une base semée à neuf : un seul mois porte des créneaux, le mois courant. Le défaut ne se voyait pas en développement, où la base garde les créneaux des exécutions antérieures — 39 créneaux de juillet survivaient chez moi à des semis d'il y a plusieurs semaines. **Le seed ne remettait pas l'état de publication.** Son `update` était vide, si bien qu'une semaine déjà semée gardait le statut qu'elle avait alors : la semaine précédente, publiée par définition, restait en brouillon dès qu'elle avait été semée du temps où elle était la semaine courante. D'où des tests qui échouent en local et passent en intégration continue — l'écart le plus coûteux à diagnostiquer. Le statut est désormais réimposé. **Ports internes.** L'application écoute sur 9317 et la base sur 5439, jusque dans l'image. Sur un réseau Docker deux conteneurs peuvent écouter le même port sans se gêner — ce n'est donc pas une correction de collision — mais une valeur unique de bout en bout lève l'ambiguïté quand plusieurs piles cohabitent derrière le même proxy, et la configuration du reverse-proxy porte partout le même nombre. Publication, sonde de santé, serveur, chaîne de connexion et scripts d'amorçage sont alignés sur une seule variable par service. `pnpm verify` : 450 tests. Playwright : 77/77 après remise à zéro du semis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
225 lines
10 KiB
YAML
225 lines
10 KiB
YAML
name: planflow
|
|
|
|
services:
|
|
db:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
|
# Valeur par défaut assumée : la base n'est jamais publiée et reste sur le
|
|
# réseau privé de la pile, où seule l'application l'atteint. Ce mot de
|
|
# passe ne protège donc pas d'Internet — il protège d'un autre conteneur
|
|
# du même réseau. Le changer reste recommandé.
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
|
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
|
# Port d'écoute **dans** le conteneur. Un port non commun ne protège de
|
|
# rien — sur un réseau Docker, chaque conteneur a son adresse et deux
|
|
# services peuvent écouter le même port sans se gêner — mais il lève
|
|
# l'ambiguïté quand plusieurs bases cohabitent derrière le même proxy.
|
|
PGPORT: ${POSTGRES_PORT_INTERNAL:-5439}
|
|
# Transmis au script d'initialisation, qui crée le rôle de connexion de
|
|
# l'application — celui-ci ne doit surtout pas être le superutilisateur.
|
|
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
|
|
# Deterministic collation: ordering of employee names must not depend on
|
|
# the host locale, or exports differ between machines.
|
|
LANG: C.UTF-8
|
|
# Réaligne le mot de passe du compte d'amorçage à **chaque** démarrage.
|
|
#
|
|
# `POSTGRES_PASSWORD` n'est lu qu'à la création du volume. Un volume né d'un
|
|
# déploiement antérieur garde donc le mot de passe d'alors, et plus rien
|
|
# dans la pile ne peut le corriger : ni l'application, ni `db-init`, tous
|
|
# deux refusés à l'entrée. La seule position d'où la réparation est possible
|
|
# est l'intérieur de ce conteneur, où PostgreSQL accepte la socket locale
|
|
# sans mot de passe.
|
|
#
|
|
# Sans cela, une installation ayant connu deux valeurs de mot de passe ne
|
|
# redémarre plus jamais sans SQL à la main — et le symptôme, un `P1000` sur
|
|
# le rôle applicatif, désigne le mauvais coupable.
|
|
#
|
|
# Le SQL passe par l'entrée standard et non par `-c` : `psql -c` n'interpole
|
|
# pas les variables, si bien que `:'pw'` y serait envoyé littéralement. Par
|
|
# l'entrée standard, c'est psql qui met le mot de passe entre guillemets —
|
|
# une apostrophe dans le mot de passe ne casse donc rien.
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
(
|
|
tentative=0
|
|
while [ $$tentative -lt 60 ]; do
|
|
if echo "ALTER USER \"$$POSTGRES_USER\" WITH PASSWORD :'pw';" \
|
|
| psql -q -v ON_ERROR_STOP=1 -v pw="$$POSTGRES_PASSWORD" \
|
|
-U "$$POSTGRES_USER" -d postgres >/dev/null 2>&1; then
|
|
echo "[db] Mot de passe du compte d'amorçage réaligné sur la pile."
|
|
break
|
|
fi
|
|
tentative=$$((tentative + 1))
|
|
sleep 1
|
|
done
|
|
) &
|
|
exec docker-entrypoint.sh postgres -p "$$PGPORT"
|
|
volumes:
|
|
- db-data:/var/lib/postgresql/data
|
|
# Crée le rôle applicatif à la première initialisation du volume.
|
|
- ./docker/init-app-role.sh:/docker-entrypoint-initdb.d/10-init-app-role.sh:ro
|
|
healthcheck:
|
|
test:
|
|
[
|
|
'CMD-SHELL',
|
|
'pg_isready -p ${POSTGRES_PORT_INTERNAL:-5439} -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}',
|
|
]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
# `interne` suffit à l'application : les deux services s'y joignent déjà, et
|
|
# rien de ce que fait la base ne passe par le reverse-proxy.
|
|
#
|
|
# `nginx_default` est ajouté à la demande de l'exploitant. Il faut en
|
|
# mesurer la portée : la base devient joignable par **tous** les conteneurs
|
|
# que sert ce proxy, et non plus par la seule application.
|
|
# `POSTGRES_PASSWORD` cesse alors d'être une formalité — sa valeur par
|
|
# défaut ne protège plus rien d'utile, il faut la changer.
|
|
#
|
|
# Autre effet à connaître : le nom `db` est publié sur ce réseau partagé. Un
|
|
# autre service nommé `db` qui s'y attacherait rendrait la résolution
|
|
# ambiguë pour les deux piles.
|
|
networks:
|
|
- interne
|
|
- nginx_default
|
|
# Ni le port du conteneur ni celui de l'hôte ne sont les ports usuels :
|
|
# plusieurs instances PostgreSQL coexistent sur un même serveur, et s'en
|
|
# tenir au 5432 partout invite la confusion.
|
|
#
|
|
# **Lié à la boucle locale**, et c'est important : la publication sert à se
|
|
# connecter depuis le serveur (sauvegarde, psql), pas depuis le réseau. Sans
|
|
# `127.0.0.1`, Docker ouvre le port sur toutes les interfaces — un jeu de
|
|
# données RH derrière un mot de passe par défaut, joignable depuis
|
|
# l'extérieur. Pour un accès distant, passer par un tunnel.
|
|
ports:
|
|
- '${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-55432}:${POSTGRES_PORT_INTERNAL:-5439}'
|
|
|
|
# Pose le rôle applicatif à **chaque** démarrage de la pile.
|
|
#
|
|
# `/docker-entrypoint-initdb.d` ne s'exécute qu'à la toute première
|
|
# initialisation du volume : une base dont le volume existait déjà — ou dont
|
|
# le mot de passe a changé — laisserait l'application en échec
|
|
# d'authentification P1000, sans recours autre que du SQL à la main. Le script
|
|
# est rejouable, ce service le rejoue.
|
|
#
|
|
# Les identifiants du superutilisateur restent ici : les confier au conteneur
|
|
# applicatif lui donnerait de quoi contourner la row-level security, ce que
|
|
# tout ce montage cherche précisément à empêcher.
|
|
db-init:
|
|
image: postgres:16-alpine
|
|
restart: 'no'
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
PGHOST: db
|
|
PGPORT: ${POSTGRES_PORT_INTERNAL:-5439}
|
|
PGPASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
|
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
|
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
|
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
|
|
volumes:
|
|
- ./docker/init-app-role.sh:/init-app-role.sh:ro
|
|
# `entrypoint` et non `command` : l'image postgres a son propre point
|
|
# d'entrée, qui tenterait de démarrer un serveur.
|
|
entrypoint: ['sh', '/init-app-role.sh']
|
|
networks:
|
|
- interne
|
|
|
|
app:
|
|
build:
|
|
context: .
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
db-init:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
NODE_ENV: production
|
|
# Port d'écoute du serveur **dans** le conteneur. C'est celui que vise
|
|
# le reverse-proxy ; il doit rester le même que celui du healthcheck et
|
|
# de la publication, d'où l'unique variable.
|
|
PORT: ${APP_PORT_INTERNAL:-9317}
|
|
# Le compte applicatif, **pas** le superutilisateur d'amorçage : un
|
|
# superutilisateur contourne la row-level security, y compris déclarée en
|
|
# FORCE, et l'isolation ne reposerait plus que sur la couche applicative.
|
|
DATABASE_URL: postgresql://${APP_DB_USER:-planflow_app}:${APP_DB_PASSWORD:-planflow-app-interne}@db:${POSTGRES_PORT_INTERNAL:-5439}/${POSTGRES_DB:-planflow}
|
|
# Laissée vide, elle est **produite au premier démarrage** et conservée
|
|
# dans le volume `secrets`. La renseigner ici reste possible pour un
|
|
# déploiement qui gère ses secrets par ailleurs — mais une variable
|
|
# d'environnement s'affiche dans `docker inspect` et dans l'interface de
|
|
# gestion, ce qui n'en fait pas un meilleur coffre qu'un fichier.
|
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
|
|
APP_URL: ${APP_URL:-http://localhost:9317}
|
|
# Chemin **dans le volume**, pas dans l'image : les pièces du dossier
|
|
# salarié écrites dans la couche du conteneur disparaîtraient au premier
|
|
# redéploiement, et une pièce d'identité perdue ne se reconstitue pas.
|
|
DOCUMENT_STORE: /data/documents
|
|
# Amorçage du rôle applicatif depuis le conteneur, pour les
|
|
# orchestrateurs qui n'honorent pas `depends_on` — Swarm, notamment — et
|
|
# pour les piles déployées avant l'ajout de `db-init`. Le point d'entrée
|
|
# retire ces variables avant de lancer le serveur : le processus qui sert
|
|
# les requêtes ne les voit jamais.
|
|
POSTGRES_USER: ${POSTGRES_USER:-planflow}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
|
|
POSTGRES_DB: ${POSTGRES_DB:-planflow}
|
|
POSTGRES_HOST: db
|
|
POSTGRES_PORT_INTERNAL: ${POSTGRES_PORT_INTERNAL:-5439}
|
|
APP_DB_USER: ${APP_DB_USER:-planflow_app}
|
|
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
|
|
volumes:
|
|
- documents:/data
|
|
# Volume distinct de la base et des documents : sauvegarder l'un ne doit
|
|
# pas emporter la clé qui déchiffre l'autre.
|
|
- secrets:/secrets
|
|
networks:
|
|
# `interne` pour joindre la base, `nginx_default` pour être joignable par
|
|
# le reverse-proxy — qui atteint le conteneur sur son port interne, sans
|
|
# passer par le port publié.
|
|
- interne
|
|
- nginx_default
|
|
ports:
|
|
# Port peu courant : le service est censé passer par le reverse-proxy, et
|
|
# un 3000 publié sur l'hôte se heurte à tout ce qui traîne. Le port
|
|
# **interne** l'est aussi, pour que la configuration du reverse-proxy
|
|
# porte partout la même valeur.
|
|
- '${APP_PORT:-9317}:${APP_PORT_INTERNAL:-9317}'
|
|
healthcheck:
|
|
test:
|
|
[
|
|
'CMD',
|
|
'node',
|
|
'-e',
|
|
"fetch('http://127.0.0.1:${APP_PORT_INTERNAL:-9317}/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
]
|
|
interval: 15s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 30s
|
|
|
|
networks:
|
|
# Réseau privé de la pile : base et application, rien d'autre.
|
|
interne:
|
|
|
|
# Réseau du reverse-proxy, créé en dehors de cette pile. Le déclarer externe
|
|
# évite d'en fabriquer un second du même nom, sur lequel nginx ne verrait rien.
|
|
nginx_default:
|
|
external: true
|
|
|
|
volumes:
|
|
db-data:
|
|
# Clé de chiffrement produite au premier démarrage. À sauvegarder
|
|
# **séparément** du reste : réunis, le coffre et sa clé ne protègent plus rien.
|
|
secrets:
|
|
# Pièces du dossier salarié, chiffrées au repos avec ENCRYPTION_KEY. À
|
|
# sauvegarder avec la base : l'une sans l'autre restitue un dossier amputé,
|
|
# et sans la clé le volume est illisible.
|
|
documents:
|