Réparer l'intégration continue, et poser des ports internes non communs

**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
This commit is contained in:
Claude committed 2026-08-10 08:10:35 +00:00
1 parent dd60dc33b8
commit b184109e23
6 files changed
+70 -19

No files matched your search

+6 -2
View File
@@ -71,7 +71,11 @@ RUN chmod +x ./docker/entrypoint.sh
RUN mkdir -p /data/documents /secrets && chown -R nextjs:nodejs /data /secrets RUN mkdir -p /data/documents /secrets && chown -R nextjs:nodejs /data /secrets
USER nextjs USER nextjs
EXPOSE 3000 # Port peu courant jusque **dans** l'image : le reverse-proxy porte alors la
ENV PORT=3000 HOSTNAME=0.0.0.0 # même valeur partout, et rien ne rappelle un 3000 par défaut. Surchargeable
# par la pile, qui aligne publication, sonde de santé et serveur sur une
# unique variable.
EXPOSE 9317
ENV PORT=9317 HOSTNAME=0.0.0.0
CMD ["./docker/entrypoint.sh"] CMD ["./docker/entrypoint.sh"]
+28 -12
View File
@@ -12,6 +12,11 @@ services:
# du même réseau. Le changer reste recommandé. # du même réseau. Le changer reste recommandé.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
POSTGRES_DB: ${POSTGRES_DB:-planflow} 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 # 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. # l'application — celui-ci ne doit surtout pas être le superutilisateur.
APP_DB_USER: ${APP_DB_USER:-planflow_app} APP_DB_USER: ${APP_DB_USER:-planflow_app}
@@ -53,13 +58,17 @@ services:
sleep 1 sleep 1
done done
) & ) &
exec docker-entrypoint.sh postgres exec docker-entrypoint.sh postgres -p "$$PGPORT"
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
# Crée le rôle applicatif à la première initialisation du volume. # 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 - ./docker/init-app-role.sh:/docker-entrypoint-initdb.d/10-init-app-role.sh:ro
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}'] test:
[
'CMD-SHELL',
'pg_isready -p ${POSTGRES_PORT_INTERNAL:-5439} -U ${POSTGRES_USER:-planflow} -d ${POSTGRES_DB:-planflow}',
]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -78,10 +87,9 @@ services:
networks: networks:
- interne - interne
- nginx_default - nginx_default
# Le conteneur reste sur 5432 pour l'application, et le port hôte est publié # Ni le port du conteneur ni celui de l'hôte ne sont les ports usuels :
# sur un port non commun, configurable : plusieurs instances PostgreSQL # plusieurs instances PostgreSQL coexistent sur un même serveur, et s'en
# coexistent sur le même serveur, et s'en tenir au 5432 par défaut les # tenir au 5432 partout invite la confusion.
# ferait entrer en collision.
# #
# **Lié à la boucle locale**, et c'est important : la publication sert à se # **Lié à la boucle locale**, et c'est important : la publication sert à se
# connecter depuis le serveur (sauvegarde, psql), pas depuis le réseau. Sans # connecter depuis le serveur (sauvegarde, psql), pas depuis le réseau. Sans
@@ -89,7 +97,7 @@ services:
# données RH derrière un mot de passe par défaut, joignable depuis # 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. # l'extérieur. Pour un accès distant, passer par un tunnel.
ports: ports:
- '${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-55432}:5432' - '${POSTGRES_BIND:-127.0.0.1}:${POSTGRES_PORT:-55432}:${POSTGRES_PORT_INTERNAL:-5439}'
# Pose le rôle applicatif à **chaque** démarrage de la pile. # Pose le rôle applicatif à **chaque** démarrage de la pile.
# #
@@ -110,6 +118,7 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
PGHOST: db PGHOST: db
PGPORT: ${POSTGRES_PORT_INTERNAL:-5439}
PGPASSWORD: ${POSTGRES_PASSWORD:-planflow-interne} PGPASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
POSTGRES_USER: ${POSTGRES_USER:-planflow} POSTGRES_USER: ${POSTGRES_USER:-planflow}
POSTGRES_DB: ${POSTGRES_DB:-planflow} POSTGRES_DB: ${POSTGRES_DB:-planflow}
@@ -134,10 +143,14 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
environment: environment:
NODE_ENV: production 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 # Le compte applicatif, **pas** le superutilisateur d'amorçage : un
# superutilisateur contourne la row-level security, y compris déclarée en # superutilisateur contourne la row-level security, y compris déclarée en
# FORCE, et l'isolation ne reposerait plus que sur la couche applicative. # 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:5432/${POSTGRES_DB:-planflow} 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 # Laissée vide, elle est **produite au premier démarrage** et conservée
# dans le volume `secrets`. La renseigner ici reste possible pour un # dans le volume `secrets`. La renseigner ici reste possible pour un
# déploiement qui gère ses secrets par ailleurs — mais une variable # déploiement qui gère ses secrets par ailleurs — mais une variable
@@ -158,6 +171,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-planflow-interne}
POSTGRES_DB: ${POSTGRES_DB:-planflow} POSTGRES_DB: ${POSTGRES_DB:-planflow}
POSTGRES_HOST: db POSTGRES_HOST: db
POSTGRES_PORT_INTERNAL: ${POSTGRES_PORT_INTERNAL:-5439}
APP_DB_USER: ${APP_DB_USER:-planflow_app} APP_DB_USER: ${APP_DB_USER:-planflow_app}
APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne} APP_DB_PASSWORD: ${APP_DB_PASSWORD:-planflow-app-interne}
volumes: volumes:
@@ -167,21 +181,23 @@ services:
- secrets:/secrets - secrets:/secrets
networks: networks:
# `interne` pour joindre la base, `nginx_default` pour être joignable par # `interne` pour joindre la base, `nginx_default` pour être joignable par
# le reverse-proxy — qui atteint le conteneur sur son port 3000, sans # le reverse-proxy — qui atteint le conteneur sur son port interne, sans
# passer par le port publié. # passer par le port publié.
- interne - interne
- nginx_default - nginx_default
ports: ports:
# Port peu courant : le service est censé passer par le reverse-proxy, et # 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. # un 3000 publié sur l'hôte se heurte à tout ce qui traîne. Le port
- '${APP_PORT:-9317}:3000' # **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: healthcheck:
test: test:
[ [
'CMD', 'CMD',
'node', 'node',
'-e', '-e',
"fetch('http://127.0.0.1:3000/api/sante').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", "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 interval: 15s
timeout: 5s timeout: 5s
+4 -1
View File
@@ -22,7 +22,10 @@ const superUser = process.env.POSTGRES_USER ?? 'planflow';
const superPassword = process.env.POSTGRES_PASSWORD ?? ''; const superPassword = process.env.POSTGRES_PASSWORD ?? '';
const database = process.env.POSTGRES_DB ?? 'planflow'; const database = process.env.POSTGRES_DB ?? 'planflow';
const host = process.env.POSTGRES_HOST ?? 'db'; const host = process.env.POSTGRES_HOST ?? 'db';
const port = Number(process.env.POSTGRES_PORT_INTERNAL ?? 5432); // Le port d'écoute **dans** le conteneur de la base, que la pile choisit peu
// commun. Le défaut suit celui du compose, pas celui de PostgreSQL : une
// valeur qui diverge se solderait par un refus de connexion à l'amorçage.
const port = Number(process.env.POSTGRES_PORT_INTERNAL ?? 5439);
const appRole = process.env.APP_DB_USER ?? 'planflow_app'; const appRole = process.env.APP_DB_USER ?? 'planflow_app';
const appPassword = process.env.APP_DB_PASSWORD ?? 'planflow-app-interne'; const appPassword = process.env.APP_DB_PASSWORD ?? 'planflow-app-interne';
+3 -1
View File
@@ -28,7 +28,9 @@ DB_USER="${POSTGRES_USER:-planflow}"
# Dans le conteneur `db`, l'hôte est local ; depuis `db-init`, c'est `db`. # Dans le conteneur `db`, l'hôte est local ; depuis `db-init`, c'est `db`.
# L'un et l'autre doivent aboutir au même SQL. # L'un et l'autre doivent aboutir au même SQL.
if [ -n "${PGHOST:-}" ]; then if [ -n "${PGHOST:-}" ]; then
set -- -h "$PGHOST" -p "${PGPORT:-5432}" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 # Défaut aligné sur celui de la pile, et non sur celui de PostgreSQL : la
# base écoute un port peu commun, et 5432 ne joindrait rien.
set -- -h "$PGHOST" -p "${PGPORT:-5439}" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1
else else
set -- -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 set -- -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1
fi fi
+12 -1
View File
@@ -819,7 +819,18 @@ async function seedPlanning(accountId: string): Promise<void> {
isoWeek: week.isoWeek, isoWeek: week.isoWeek,
}, },
}, },
update: {}, // L'état de publication est **réimposé**, pas seulement posé à la
// création. Un seed qui laisse en place ce qu'il trouve ne produit un
// état de départ connu que la première fois : la semaine précédente,
// publiée par définition, reste en brouillon dès lors qu'elle a déjà
// été semée quand elle était la semaine courante. Les tests qui
// s'appuient dessus échouent alors sur une base de développement et
// passent en intégration continue, où le semis est neuf — l'écart le
// plus coûteux à diagnostiquer.
update: {
status: week === previous ? 'PUBLISHED' : 'DRAFT',
publishedAt: week === previous ? new Date() : null,
},
create: { create: {
accountId, accountId,
teamId: team.id, teamId: team.id,
+17 -2
View File
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/planning/month'; import { formatMonthParam, monthOf } from '../../src/domain/planning/month';
/** /**
* Heures et périodes de paie. * Heures et périodes de paie.
@@ -10,7 +10,22 @@ import { formatMonthParam, monthOf, previousMonth } from '../../src/domain/plann
* plutôt qu'un bouton. * plutôt qu'un bouton.
*/ */
const MONTH = formatMonthParam(previousMonth(monthOf(new Date()))); /**
* Mois **courant**, et non le précédent.
*
* Le seed ne pose que deux semaines de planning : la courante et la
* précédente. Le mois précédent n'en contient donc aucune, sauf quand on joue
* la suite dans les premiers jours d'un mois — le tableau est vide le reste du
* temps, et l'assertion échoue pour une raison sans rapport avec ce qu'elle
* vérifie. Le défaut ne se voyait pas sur une base de développement, qui garde
* les créneaux des exécutions précédentes ; l'intégration continue, elle,
* repart d'un semis neuf.
*
* Le mois courant, lui, contient toujours aujourd'hui — donc toujours des
* créneaux, et aucun n'a d'heures réelles puisque rien dans le semis ni dans la
* suite n'en saisit.
*/
const MONTH = formatMonthParam(monthOf(new Date()));
/** /**
* Mois propre à cette exécution. * Mois propre à cette exécution.