**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
163 lines
6.5 KiB
JavaScript
163 lines
6.5 KiB
JavaScript
/**
|
||
* Pose le rôle applicatif depuis le conteneur de l'application.
|
||
*
|
||
* Le service `db-init` fait déjà ce travail, mais il suppose un orchestrateur
|
||
* qui honore `depends_on: service_completed_successfully` — ce que Swarm ignore,
|
||
* et ce qu'une pile déployée avant l'ajout du service ne contient même pas.
|
||
* L'application se retrouve alors à redémarrer en boucle sur un refus
|
||
* d'authentification que personne ne peut corriger sans intervenir à la main.
|
||
*
|
||
* Elle le corrige donc elle-même, **si** on lui confie de quoi le faire. Sans
|
||
* `POSTGRES_PASSWORD`, ce script ne fait rien et se tait : un déploiement qui
|
||
* préfère garder les identifiants d'amorçage hors du conteneur applicatif reste
|
||
* libre de le faire.
|
||
*
|
||
* Le point d'entrée retire ces variables de l'environnement avant de lancer le
|
||
* serveur : le processus qui sert les requêtes ne les voit jamais, et une
|
||
* exécution de code arbitraire dans l'application n'y donne pas accès.
|
||
*/
|
||
import { Client } from 'pg';
|
||
|
||
const superUser = process.env.POSTGRES_USER ?? 'planflow';
|
||
const superPassword = process.env.POSTGRES_PASSWORD ?? '';
|
||
const database = process.env.POSTGRES_DB ?? 'planflow';
|
||
const host = process.env.POSTGRES_HOST ?? 'db';
|
||
// 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 appPassword = process.env.APP_DB_PASSWORD ?? 'planflow-app-interne';
|
||
|
||
if (!superPassword) {
|
||
console.log(
|
||
'[bootstrap] Aucun identifiant d’amorçage fourni : le rôle applicatif est supposé déjà en place.',
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
/**
|
||
* Un identifiant ne se met pas entre guillemets simples comme une valeur.
|
||
* `quote_ident` n'étant pas disponible côté client, on refuse ce qui n'a pas la
|
||
* forme d'un identifiant plutôt que de fabriquer une injection.
|
||
*/
|
||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(appRole)) {
|
||
console.error(`[bootstrap] Nom de rôle invalide : ${appRole}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const client = new Client({
|
||
host,
|
||
port,
|
||
user: superUser,
|
||
password: superPassword,
|
||
database,
|
||
});
|
||
|
||
try {
|
||
await client.connect();
|
||
|
||
// Le branchement se fait ici, pas dans un bloc `DO` : à l'intérieur d'un
|
||
// `DO`, le corps est une **chaîne littérale**, et `$1` n'y est pas un
|
||
// paramètre de requête — le serveur répond « bind message supplies 1
|
||
// parameters, but prepared statement requires 0 ».
|
||
const existing = await client.query(
|
||
'SELECT 1 FROM pg_roles WHERE rolname = $1',
|
||
[appRole],
|
||
);
|
||
|
||
// L'échappement est confié à PostgreSQL : un mot de passe peut contenir une
|
||
// apostrophe, et la concaténer à la main serait une injection.
|
||
const quoted = await client.query('SELECT quote_literal($1) AS literal', [
|
||
appPassword,
|
||
]);
|
||
const literal = quoted.rows[0].literal;
|
||
|
||
const attributes = 'NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS';
|
||
await client.query(
|
||
existing.rowCount === 0
|
||
? `CREATE ROLE ${appRole} LOGIN PASSWORD ${literal} ${attributes}`
|
||
: `ALTER ROLE ${appRole} WITH LOGIN PASSWORD ${literal} ${attributes}`,
|
||
);
|
||
|
||
await client.query(`ALTER DATABASE "${database}" OWNER TO ${appRole}`);
|
||
await client.query(`ALTER SCHEMA public OWNER TO ${appRole}`);
|
||
await client.query(`GRANT ALL ON SCHEMA public TO ${appRole}`);
|
||
|
||
// Objets créés par le compte d'amorçage lors d'un déploiement antérieur :
|
||
// sans ce transfert, le rôle applicatif ne pourrait ni migrer ni lire.
|
||
await client.query(`
|
||
DO $own$
|
||
DECLARE statement text;
|
||
BEGIN
|
||
FOR statement IN
|
||
SELECT format('ALTER TABLE %I.%I OWNER TO ${appRole}', schemaname, tablename)
|
||
FROM pg_tables WHERE schemaname = 'public'
|
||
UNION ALL
|
||
SELECT format('ALTER SEQUENCE %I.%I OWNER TO ${appRole}', sequence_schema, sequence_name)
|
||
FROM information_schema.sequences WHERE sequence_schema = 'public'
|
||
LOOP
|
||
EXECUTE statement;
|
||
END LOOP;
|
||
END
|
||
$own$;`);
|
||
|
||
console.log(
|
||
`[bootstrap] Rôle ${appRole} prêt — NOSUPERUSER NOBYPASSRLS, propriétaire de ${database}.`,
|
||
);
|
||
} catch (error) {
|
||
// Non bloquant : le rôle est peut-être déjà correct et posé par `db-init`.
|
||
// Faire échouer le démarrage ici priverait d'une installation qui marche.
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
console.error(`[bootstrap] Provisionnement impossible (${message}).`);
|
||
|
||
// Le cas de très loin le plus fréquent, et le moins évident : le compte
|
||
// d'amorçage lui-même est refusé.
|
||
//
|
||
// `POSTGRES_PASSWORD` n'est lu qu'à **l'initialisation du volume**. Un volume
|
||
// créé lors d'un déploiement antérieur garde le mot de passe d'alors, et le
|
||
// changer dans la pile n'y touche pas — le volume ne se réinitialise jamais.
|
||
// Sans cette explication, on cherche indéfiniment du côté du rôle applicatif,
|
||
// qui n'y est pour rien.
|
||
if (/password authentication failed/i.test(message)) {
|
||
console.error('');
|
||
console.error(
|
||
`[bootstrap] Le compte d’amorçage « ${superUser} » est lui-même refusé.`,
|
||
);
|
||
console.error(
|
||
'[bootstrap] POSTGRES_PASSWORD n’est lu qu’à la création du volume de la',
|
||
);
|
||
console.error(
|
||
'[bootstrap] base. Si ce volume vient d’un déploiement antérieur, il porte',
|
||
);
|
||
console.error(
|
||
'[bootstrap] encore l’ancien mot de passe, et le changer dans la pile n’y',
|
||
);
|
||
console.error('[bootstrap] change rien.');
|
||
console.error('');
|
||
console.error('[bootstrap] • Base encore vide — repartir d’un volume neuf :');
|
||
console.error('[bootstrap] docker compose down');
|
||
console.error('[bootstrap] docker volume rm planflow_db-data');
|
||
console.error('[bootstrap] docker compose up -d');
|
||
console.error('');
|
||
console.error('[bootstrap] • Base à conserver — réaligner le mot de passe.');
|
||
console.error(
|
||
'[bootstrap] Par la socket locale, qui ne demande pas l’ancien :',
|
||
);
|
||
console.error(
|
||
`[bootstrap] docker compose exec db psql -U ${superUser} \\`,
|
||
);
|
||
console.error(
|
||
`[bootstrap] -c "ALTER USER ${superUser} PASSWORD '<celui de la pile>';"`,
|
||
);
|
||
console.error('');
|
||
}
|
||
|
||
console.error(
|
||
'[bootstrap] La suite dira si le rôle applicatif est utilisable en l’état.',
|
||
);
|
||
} finally {
|
||
await client.end().catch(() => undefined);
|
||
}
|