La sandbox
Le site entier — application, base, stockage, boîte e-mail — lancé par une seule commande, sans compte ni service extérieur, avec des comptes de démonstration : pour montrer le projet, essayer une idée ou tout casser sans conséquence. Comment les services s’attendent, comment le navigateur atteint un stockage qui n’existe que dans Docker, et comment tout effacer.
Précédemment. La production suit un ordre précis — le code, la migration, le nouveau conteneur — et le site garantit lui-même, en démarrant, les données dont il a besoin.
Le projet est aussi une vitrine : quelqu’un qui veut l’essayer ne doit avoir besoin ni d’un compte Cloudflare, ni d’une base Postgres, ni d’un serveur SMTP, ni des données des membres. La sandbox est un fichier compose qui monte tout cela sur sa machine, avec Docker :
git clone … && cd akfcdocker compose -f docker-compose.sandbox.yml up -d --buildLe site répond alors sur http://localhost:3010, la console du stockage sur http://localhost:9001, et les e-mails envoyés par le site s’affichent sur http://localhost:8025. Rien ne sort de la machine.
Sept services, dans l’ordre 🧩#
Service | Image | Rôle |
|---|---|---|
postgres | postgres:16-alpine | La base, dans un volume nommé : elle survit à un redémarrage. |
minio | minio/minio | Le stockage compatible S3, à la place de Cloudflare R2. |
minio-init | minio/mc | Crée le bucket au démarrage, puis s’arrête. |
mailpit | axllent/mailpit | Capture tous les e-mails envoyés, et les affiche dans une page web. |
migrator | la cible migrator du Dockerfile | Applique les migrations, puis s’arrête. |
seeder | la même image | Crée les comptes et le contenu de démonstration, puis s’arrête. |
app | la cible runner du Dockerfile | Le site, construit comme en production. |
Showing entries1to7entries
L’application de la sandbox est l’image de production (chapitre 1), construite à partir du même Dockerfile. Seuls l’environnement et les services autour changent : ce qu’on y essaie est ce qui tourne pour les membres.
docker-compose.sandbox.yml
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
// ⚠ Fichier introuvable : docker-compose.sandbox.yml// Il a été déplacé ou supprimé depuis la rédaction de ce chapitre.Les valeurs de la sandbox sont publiques : elles sont dans un dépôt ouvert. Elles ne se réutilisent jamais en production, même pour essayer.
Une adresse pour le serveur, une autre pour le navigateur#
Le site envoie les fichiers au stockage par des URL signées : le navigateur les reçoit et y dépose le fichier directement (partie « Stockage et médias »). Dans la sandbox, le site parle à MinIO sous le nom minio:9000, qui n’existe que dans le réseau Docker ; le navigateur, lui, l’atteint par localhost:9000, le port publié. Les URL destinées au navigateur sont donc signées par un client à part :
/** * Client S3 dédié au PRESIGNING d'URLs consommées par le NAVIGATEUR. * En prod : R2_PUBLIC_ENDPOINT absent → endpoint R2 public (Cloudflare), * joignable par le client. En sandbox : R2_PUBLIC_ENDPOINT=localhost:9000 * (car `minio:9000`, nom Docker, n'est pas résoluble côté navigateur). * Checksum SDK désactivé (CRC calculé sur corps vide → rejeté par MinIO). */let cachedPresign: S3Client | null = null;export function getR2PresignClient(): S3Client { if (cachedPresign) return cachedPresign; const publicEndpoint = process.env.R2_PUBLIC_ENDPOINT ?? readEnv("R2_ENDPOINT");En production, R2_PUBLIC_ENDPOINT est absente : serveur et navigateur atteignent R2 à la même adresse publique. La signature couvre l’adresse : une URL signée pour minio:9000 puis réécrite en localhost:9000 serait refusée. Il faut signer directement pour l’adresse que le navigateur utilisera.
Des comptes connus#
Le seed de la sandbox crée ce qu’il faut pour se connecter et essayer :
prisma/seed.sandbox.mjs
Clique une étape pour mettre en évidence la zone correspondante dans le fichier complet.
#!/usr/bin/env node// Seed DÉDIÉ à la sandbox recruteurs — identifiants CONNUS, idempotent.// La production n'a pas de seed : ses données sont saisies sur le site. Lancé par le service `seeder`.import { PrismaClient } from "@prisma/client";import bcrypt from "bcryptjs";const prisma = new PrismaClient();const DEMO_PASSWORD = "sandbox-demo";async function ensureAdminGroup() { let group = await prisma.memberGroup.findFirst({ where: { isAdminGroup: true }, select: { id: true }, }); if (!group) { group = await prisma.memberGroup.create({ data: { name: "Administrateurs", isCollaborative: true, isAdminGroup: true }, select: { id: true }, }); } return group.id;}async function ensureUser({ email, firstName, hash, admin, adminGroupId }) { const user = await prisma.user.upsert({ where: { email }, update: { password: hash, emailVerified: true, isFirstLogin: false }, create: { email, firstName, password: hash, emailVerified: true, isFirstLogin: false, }, select: { id: true }, }); if (admin) { await prisma.memberGroupMembership.upsert({ where: { groupId_userId: { groupId: adminGroupId, userId: user.id } }, update: {}, create: { groupId: adminGroupId, userId: user.id, access: "EDITOR" }, }); } return user.id;}async function main() { console.log("🌱 Seed sandbox…"); const hash = await bcrypt.hash(DEMO_PASSWORD, 12); const adminGroupId = await ensureAdminGroup(); const adminId = await ensureUser({ email: "admin@akfc.demo", firstName: "Admin", hash, admin: true, adminGroupId, }); await ensureUser({ email: "membre@akfc.demo", firstName: "Membre", hash, admin: false, adminGroupId, }); console.log("✅ Comptes de démo prêts"); // Catégorie « Cours » — physique `courses` via categoryStorageSegment. const coursCategory = await prisma.category.upsert({ where: { type: "Cours" }, update: {}, create: { type: "Cours" }, select: { id: true }, }); console.log("✅ Catégorie « Cours » prête"); // Disciplines (les « cours »). Le NOM porte accents/espaces ; le dossier // physique est slugifié à l'upload par le code → `courses/<slug>`. Le `slug` // sert aux URLs publiques. Instructeur = l'admin (champ requis). const DISCIPLINES = [ { name: "Kali Escrima", slug: "kali-escrima" }, { name: "Taolu multi-styles", slug: "taolu-multi-styles" }, { name: "Tchoy Lee Fut", slug: "tchoy-lee-fut" }, { name: "Taïchi Chuan", slug: "taichi-chuan" }, ]; for (const d of DISCIPLINES) { await prisma.discipline.upsert({ where: { categoryId_name: { categoryId: coursCategory.id, name: d.name } }, update: { slug: d.slug }, create: { name: d.name, slug: d.slug, type: "MARTIAL_ART", categoryId: coursCategory.id, instructorId: adminId, }, }); } console.log("✅ Disciplines prêtes"); console.log(""); console.log(" Admin : admin@akfc.demo / " + DEMO_PASSWORD); console.log(" Membre : membre@akfc.demo / " + DEMO_PASSWORD); console.log("🌱 Seed sandbox terminé");}main() .catch((e) => { console.error("❌ Seed sandbox échoué :", e); process.exit(1); }) .finally(() => prisma.$disconnect());Le seed est idempotent : relancé à chaque up, il complète sans dupliquer. Ce que l’essai a créé (des membres, des pages, des fichiers) reste d’un redémarrage à l’autre, dans les volumes.
Tout effacer#
Commande | Ce qu’elle fait |
|---|---|
docker compose -f docker-compose.sandbox.yml up -d --build | Construit et démarre tout ; relancée, reprend l’essai là où il était. |
docker compose -f docker-compose.sandbox.yml stop | Arrête les conteneurs, garde les données. |
docker compose -f docker-compose.sandbox.yml down -v | Supprime les conteneurs ET les volumes : base et fichiers repartent de zéro au prochain up. |
Showing entries1to3entries
-v est ce qui distingue « arrêter » de « effacer » : sans lui, les volumes nommés (sandbox_pgdata, sandbox_minio) restent, avec tout ce qu’ils contiennent.
La sandbox, l’environnement de développement (docker-compose.dev.yml, partie « Démarrer ») et les tests de bout en bout (partie « Qualité ») ont chacun leurs ports, leur base et leur stockage : ils peuvent tourner ensemble sur la même machine sans se marcher dessus.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Que faut-il pour lancer la sandbox ? | Docker, et une commande : docker compose -f docker-compose.sandbox.yml up -d --build. |
Le site de la sandbox est-il celui de la production ? | Oui : la même image (cible runner) ; seuls l’environnement et les services autour changent. |
Comment les services s’attendent-ils ? | depends_on : service_healthy pour la base, service_completed_successfully pour la migration et le seed. |
Comment le navigateur atteint-il MinIO ? | Par un client de signature à part, sur R2_PUBLIC_ENDPOINT (localhost:9000), l’adresse que la signature couvre. |
Avec quels comptes se connecter ? | Un administrateur et un membre de démonstration, remis à neuf à chaque démarrage. |
Comment tout remettre à zéro ? | down -v : conteneurs et volumes supprimés. |
Showing entries1to6entries
À suivre#
Cette partie a mis le site en ligne et l’a copié pour l’essayer. Reste à protéger ce que la production contient et qu’aucune image ne recrée : la base et les fichiers des membres.
À venir : les sauvegardes. Ce qui se sauvegarde, où, à quelle fréquence, chiffré comment, et comment on vérifie qu’une sauvegarde se restaure.