diff --git a/Dockerfile b/Dockerfile index 95b6829..af31c49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,10 +63,11 @@ COPY --from=builder /app/dist ./dist # Copy drizzle migrations COPY drizzle/ ./drizzle/ COPY drizzle.config.ts ./ +COPY scripts/ensure-storage-volume.sh ./scripts/ensure-storage-volume.sh -# Create uploads directory -RUN mkdir -p /app/uploads +# Create uploads directory and make the PDF volume guard executable. +RUN mkdir -p /app/uploads && chmod 755 /app/scripts/ensure-storage-volume.sh EXPOSE 3000 -CMD ["node", "dist/index.js"] +CMD ["/app/scripts/ensure-storage-volume.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index f72f99c..f3048bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,8 @@ services: JWT_SECRET: demat-facturation-jwt-secret-recette-2026 MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} UPLOAD_DIR: /app/uploads + # Chemin unique utilisé par le serveur et vérifié au démarrage. + STORAGE_BASE_PATH: /app/storage # Manus OAuth (optionnel en recette) VITE_APP_ID: ${VITE_APP_ID:-demat-facturation-recette} OAUTH_SERVER_URL: ${OAUTH_SERVER_URL:-} @@ -49,8 +51,12 @@ services: volumes: - demat_facturation_uploads:/app/uploads # Les factures source et leurs métadonnées sont servies sous /storage. - # Ce volume doit rester monté à chaque reconstruction du conteneur. - - demat_facturation_storage:/app/storage + # Il est externe et n'est donc jamais recréé ni supprimé par Compose. + - type: volume + source: demat_facturation_storage + target: /app/storage + volume: + nocopy: true networks: - demat-facturation-internal - web @@ -61,7 +67,7 @@ services: - "traefik.http.routers.demat-facturation.tls.certresolver=letsencrypt" - "traefik.http.services.demat-facturation.loadbalancer.server.port=3000" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + test: ["CMD-SHELL", "grep -qs ' /app/storage ' /proc/mounts && test -w /app/storage && curl -fsS http://localhost:3000/api/health"] interval: 30s timeout: 10s retries: 3 @@ -73,7 +79,8 @@ volumes: demat_facturation_uploads: driver: local demat_facturation_storage: - driver: local + external: true + name: ${STORAGE_VOLUME_NAME:-demat-facturation-dsi_demat_facturation_storage} networks: demat-facturation-internal: diff --git a/scripts/ensure-storage-volume.sh b/scripts/ensure-storage-volume.sh new file mode 100644 index 0000000..c6c060f --- /dev/null +++ b/scripts/ensure-storage-volume.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Refuse de lancer l'application si le répertoire PDF attendu n'est pas un +# volume Docker réellement monté. Cette barrière évite de démarrer avec un +# dossier de conteneur vide après une reconstruction. +set -eu + +storage_path="${STORAGE_BASE_PATH:-/app/storage}" + +if [ ! -d "$storage_path" ]; then + echo "[StorageGuard] Répertoire de stockage absent : $storage_path" >&2 + exit 1 +fi + +if ! awk -v expected="$storage_path" '$2 == expected { mounted = 1 } END { exit mounted ? 0 : 1 }' /proc/mounts; then + echo "[StorageGuard] Volume persistant non monté sur $storage_path ; démarrage annulé." >&2 + exit 1 +fi + +if [ ! -w "$storage_path" ]; then + echo "[StorageGuard] Volume persistant non inscriptible : $storage_path" >&2 + exit 1 +fi + +exec node dist/index.js diff --git a/server/deploymentRouting.test.ts b/server/deploymentRouting.test.ts index 40eb1b9..2d94757 100644 --- a/server/deploymentRouting.test.ts +++ b/server/deploymentRouting.test.ts @@ -2,13 +2,28 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -describe("routage Traefik", () => { +describe("routage et persistance de déploiement", () => { it("utilise le domaine fourni par l’environnement avec la recette comme repli", () => { const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8"); expect(composeFile).toContain( "Host(`${APP_DOMAIN:-demat-facturation.recette.santinova-soft.org}`)" ); - expect(composeFile).toContain("demat_facturation_storage:/app/storage"); + expect(composeFile).toContain("source: demat_facturation_storage"); + expect(composeFile).toContain("target: /app/storage"); + }); + + it("refuse de recréer le volume PDF et contrôle son montage avant le démarrage", () => { + const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8"); + const dockerfile = readFileSync(join(process.cwd(), "Dockerfile"), "utf8"); + const guardScript = readFileSync(join(process.cwd(), "scripts", "ensure-storage-volume.sh"), "utf8"); + + expect(composeFile).toContain("STORAGE_BASE_PATH: /app/storage"); + expect(composeFile).toContain("external: true"); + expect(composeFile).toContain("nocopy: true"); + expect(composeFile).toContain("grep -qs ' /app/storage ' /proc/mounts"); + expect(dockerfile).toContain('CMD ["/app/scripts/ensure-storage-volume.sh"]'); + expect(guardScript).toContain("Volume persistant non monté"); + expect(guardScript).toContain("/proc/mounts"); }); }); diff --git a/todo.md b/todo.md index e0b2a21..f73fcaa 100644 --- a/todo.md +++ b/todo.md @@ -900,3 +900,8 @@ - [x] Corriger le composant PDF sans toucher aux factures ni aux volumes persistants - [x] Vérifier l’ouverture d’un PDF en production - [x] Promouvoir ADM.pareige et Olivier Pareige au rôle administrateur afin d’afficher les factures globales + +## Prévention du démontage des PDF après reconstruction +- [x] Auditer les montages de stockage et la chaîne de reconstruction en recette et production +- [x] Ajouter un contrôle bloquant avant reconstruction et une vérification après démarrage du volume `/app/storage` +- [ ] Vérifier les garde-fous sur les deux environnements sans modifier les données