diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index fedade6..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Déploiement production - -on: - push: - branches: [main] - -concurrency: - group: deploy-production - cancel-in-progress: false - -jobs: - test: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: benevoles - POSTGRES_PASSWORD: benevoles - POSTGRES_DB: benevoles_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U benevoles" - --health-interval 5s --health-timeout 5s --health-retries 10 - env: - TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test - DATABASE_URL: postgresql+psycopg://benevoles:benevoles@localhost:5432/benevoles_test - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - with: - version: "0.5.11" - - name: Installer les dépendances - working-directory: backend - run: uv sync - - name: Lint - working-directory: backend - run: uv run ruff check . - - name: Tests - working-directory: backend - run: uv run pytest - - build: - needs: test - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository }}-backend - tags: | - type=raw,value=latest - type=sha,prefix=sha- - - uses: docker/build-push-action@v6 - with: - context: ./backend - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - deploy: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Copier la configuration de déploiement - uses: appleboy/scp-action@v0.1.7 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - source: deploy/docker-compose.prod.yml - target: ${{ secrets.DEPLOY_PATH }} - strip_components: 1 - - name: Redéployer - uses: appleboy/ssh-action@v1.2.0 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - script: | - set -e - cd "${{ secrets.DEPLOY_PATH }}" - export BACKEND_IMAGE="ghcr.io/${{ github.repository }}-backend:sha-${{ github.sha }}" - echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - docker compose -f docker-compose.prod.yml pull - docker compose -f docker-compose.prod.yml up -d - docker image prune -f diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..012b3db --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,116 @@ +# CI/CD : tests à chaque push, puis sur `main` seulement, build des images +# (registre de conteneurs GitLab) et déploiement SSH sur le VPS. +# +# Variables CI/CD à définir (Paramètres → CI/CD → Variables du projet) : +# SSH_HOST hôte du VPS +# SSH_USER utilisateur SSH +# SSH_KEY clé privée SSH (format OpenSSH), variable « protégée » +# + « masquée », type fichier ou variable multi-lignes +# DEPLOY_PATH dossier du projet sur le VPS (ex. /opt/benevoles) +# VITE_EDITION_ID UUID de l'édition de production, à renseigner APRÈS la +# toute première mise en service (voir deploy/README.md) — +# le frontend fige cette valeur au moment du build. +# +# `CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD` et +# `CI_REGISTRY_IMAGE` sont fournis automatiquement par GitLab. + +stages: + - test + - build + - deploy + +variables: + BACKEND_IMAGE: $CI_REGISTRY_IMAGE/backend + FRONTEND_IMAGE: $CI_REGISTRY_IMAGE/frontend + +backend-test: + stage: test + image: python:3.12-slim + services: + - name: postgres:16-alpine + alias: postgres + variables: + POSTGRES_USER: benevoles + POSTGRES_PASSWORD: benevoles + POSTGRES_DB: benevoles_test + TEST_DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test + DATABASE_URL: postgresql+psycopg://benevoles:benevoles@postgres:5432/benevoles_test + before_script: + - pip install --no-cache-dir uv + - cd backend + - uv sync + script: + - uv run ruff check . + - uv run alembic upgrade head + - uv run alembic check + - uv run pytest -q + +frontend-test: + stage: test + image: node:20-alpine + before_script: + - cd frontend + - npm ci + script: + - npm run typecheck + - npm run lint + - npm run build + - npm run smoke + +build-backend: + stage: build + image: docker:27 + services: + - docker:27-dind + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" + - docker build -t "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$BACKEND_IMAGE:latest" backend + - docker push "$BACKEND_IMAGE:$CI_COMMIT_SHORT_SHA" + - docker push "$BACKEND_IMAGE:latest" + +build-frontend: + stage: build + image: docker:27 + services: + - docker:27-dind + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" + - > + docker build + --build-arg VITE_API_MODE=http + --build-arg VITE_EDITION_ID="$VITE_EDITION_ID" + -f frontend/Dockerfile.prod + -t "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$FRONTEND_IMAGE:latest" + frontend + - docker push "$FRONTEND_IMAGE:$CI_COMMIT_SHORT_SHA" + - docker push "$FRONTEND_IMAGE:latest" + +deploy: + stage: deploy + image: alpine:3.20 + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + before_script: + - apk add --no-cache openssh-client rsync + - eval "$(ssh-agent -s)" + - echo "$SSH_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh && chmod 700 ~/.ssh + - ssh-keyscan -H "$SSH_HOST" >> ~/.ssh/known_hosts + script: + - rsync -az deploy/docker-compose.prod.yml "$SSH_USER@$SSH_HOST:$DEPLOY_PATH/docker-compose.prod.yml" + - | + ssh "$SSH_USER@$SSH_HOST" sh -s </-backend` → SSH sur le VPS → - `docker compose -f docker-compose.prod.yml pull && up -d`. -- Secrets requis (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`, - `SSH_KEY`, `DEPLOY_PATH`, `GHCR_TOKEN`. +- Dépôt GitLab. VPS avec Docker + un Caddy déjà installé en service système + (hors de ce dépôt), qui reçoit tout le trafic public en HTTPS et fait + reverse proxy vers les conteneurs. +- `.gitlab-ci.yml` : à chaque push, tests (backend + frontend) ; sur `main` + seulement, build des images `backend` et `frontend` (registre de conteneurs + du projet GitLab) puis SSH sur le VPS → copie de + `deploy/docker-compose.prod.yml` → `pull` → migration Alembic → `up -d`. +- Le frontend est un **build statique** (`frontend/Dockerfile.prod`, servi par + nginx) : `VITE_API_MODE`/`VITE_EDITION_ID` sont figées au moment du build, + pas à l'exécution — l'édition de production doit donc exister avant la + première construction de cette image (voir `deploy/README.md`). +- Variables CI/CD requises (voir `deploy/README.md`) : `SSH_HOST`, `SSH_USER`, + `SSH_KEY`, `DEPLOY_PATH`, `VITE_EDITION_ID`. - Le VPS porte un fichier `.env` (non versionné) avec `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`, `JWT_SECRET`, `PUBLIC_BASE_URL` et les variables `EMAIL_ENABLED` / `SMTP_*` (voir `deploy/README.md`). +- `backend` et `frontend` n'écoutent que sur `127.0.0.1` (voir + `docker-compose.prod.yml`) : `deploy/Caddyfile.benevoles` est le bloc à + ajouter au Caddyfile du serveur pour les exposer publiquement (`/api/*` vers + le backend avec le préfixe retiré, le reste vers le frontend). --- diff --git a/README.md b/README.md index 6803b39..8ee2ad9 100644 --- a/README.md +++ b/README.md @@ -47,5 +47,5 @@ démarrage du conteneur PostgreSQL. |--------------|-------------------------------------------------| | `backend/` | API FastAPI, modèle SQLModel, migrations Alembic | | `frontend/` | Interface React + Vite + TS + Tailwind (service `frontend`) | -| `deploy/` | Configuration de déploiement production | -| `.github/` | Chaîne CI/CD (build → GHCR → SSH sur le VPS) | +| `deploy/` | Configuration de déploiement production (compose, Caddy, runbook) | +| `.gitlab-ci.yml` | Chaîne CI/CD (tests → build → SSH sur le VPS) | diff --git a/deploy/Caddyfile.benevoles b/deploy/Caddyfile.benevoles new file mode 100644 index 0000000..c1cf349 --- /dev/null +++ b/deploy/Caddyfile.benevoles @@ -0,0 +1,24 @@ +# Bloc à ajouter au Caddyfile existant du serveur (ex. /etc/caddy/Caddyfile, +# ou un fichier importé par lui — voir deploy/README.md). Remplacer le nom de +# domaine par le vôtre. Caddy gère seul le certificat HTTPS (Let's Encrypt). +# +# Le frontend (nginx, port 8080) et le backend (port 8000) écoutent tous les +# deux sur 127.0.0.1 uniquement (voir docker-compose.prod.yml) : ce vhost est +# leur unique porte d'entrée publique. + +benevoles.mon-domaine.fr { + encode zstd gzip + + # API : même préfixe `/api` que le proxy Vite en développement, retiré + # avant de transmettre au backend (qui expose ses routes sous + # `/editions/...`, pas `/api`). + handle /api/* { + uri strip_prefix /api + reverse_proxy 127.0.0.1:8000 + } + + # Reste de l'application : le frontend statique (nginx). + handle { + reverse_proxy 127.0.0.1:8080 + } +} diff --git a/deploy/README.md b/deploy/README.md index 28f618a..0b44340 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,24 +1,46 @@ # Déploiement -Le workflow [`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml) -se déclenche sur **chaque push sur `main`** : +Le pipeline [`.gitlab-ci.yml`](../.gitlab-ci.yml) se déclenche sur **chaque +push** : -1. build de l'image `backend` ; -2. push sur `ghcr.io//-backend` (tags `latest` et `sha-`) ; +1. tests (backend : `ruff` + `pytest` + `alembic check` ; frontend : + `typecheck` + `lint` + `build` + `smoke`) ; +2. sur `main` seulement : build des images `backend` et `frontend`, poussées + sur le registre de conteneurs du projet GitLab ; 3. copie de `deploy/docker-compose.prod.yml` sur le VPS par SSH ; -4. `docker compose -f docker-compose.prod.yml pull && up -d`. +4. `docker compose pull`, migration Alembic, `up -d`. -## Secrets GitHub Actions requis +Le VPS n'expose rien directement : un **Caddy déjà installé sur le +serveur** (service système, hors de ce dépôt) reçoit le trafic public sur +443 et reverse-proxy vers les deux conteneurs, qui n'écoutent que sur +`127.0.0.1`. -| Secret | Description | -|----------------|----------------------------------------------------------------| -| `SSH_HOST` | Hôte du VPS | -| `SSH_USER` | Utilisateur SSH | -| `SSH_KEY` | Clé privée SSH (format OpenSSH) | -| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) | -| `GHCR_TOKEN` | PAT avec scope `read:packages`, pour que le VPS tire l'image | +## Étape 0 — pousser le dépôt sur GitLab -## Préparation du VPS (une fois) +```bash +# Créer d'abord un projet vide sur GitLab (sans README ni .gitignore), puis : +git remote add origin git@gitlab.com:/.git +git push -u origin main +``` + +## Variables CI/CD à définir (Paramètres → CI/CD → Variables) + +| Variable | Description | +|-------------------|-------------------------------------------------------------------| +| `SSH_HOST` | Hôte du VPS | +| `SSH_USER` | Utilisateur SSH | +| `SSH_KEY` | Clé privée SSH (format OpenSSH) — **masquée**, sur `main` seulement| +| `DEPLOY_PATH` | Dossier du projet sur le VPS (ex. `/opt/benevoles`) | +| `VITE_EDITION_ID` | UUID de l'édition de production — **à ajouter après** l'étape 3 | + +`CI_REGISTRY`, `CI_REGISTRY_USER`, `CI_REGISTRY_PASSWORD` et +`CI_REGISTRY_IMAGE` sont fournies automatiquement par GitLab, rien à +configurer. + +La clé publique correspondant à `SSH_KEY` doit être dans le +`~/.ssh/authorized_keys` de `SSH_USER` sur le VPS. + +## Étape 1 — préparation du VPS (une fois) ```bash mkdir -p /opt/benevoles @@ -28,21 +50,87 @@ POSTGRES_USER=benevoles POSTGRES_PASSWORD= POSTGRES_DB=benevoles JWT_SECRET= -PUBLIC_BASE_URL=https://benevoles.mon-festival.fr +PUBLIC_BASE_URL=https://benevoles.mon-domaine.fr # Emails (récapitulatifs de planning + liens de connexion). Laisser # EMAIL_ENABLED=false tant que le SMTP n'est pas prêt : les emails sont alors # seulement journalisés. EMAIL_ENABLED=true -SMTP_HOST=smtp.mon-festival.fr +SMTP_HOST=smtp.mon-domaine.fr SMTP_PORT=587 -SMTP_USER=no-reply@mon-festival.fr +SMTP_USER=no-reply@mon-domaine.fr SMTP_PASSWORD= SMTP_STARTTLS=true SMTP_SSL=false -SMTP_FROM=Festival +SMTP_FROM=Festival EOF ``` -Le workflow dépose `docker-compose.prod.yml` dans ce dossier et l'utilise tel -quel. +Le pipeline dépose `docker-compose.prod.yml` dans ce dossier et l'utilise tel +quel — il n'y a rien d'autre à y copier. + +## Étape 2 — premier déploiement (backend + base seuls) + +Le frontend a besoin de l'UUID d'une édition pour être construit (voir plus +bas) : le tout premier déploiement se fait donc en deux temps. + +1. Sur GitLab, lancer le pipeline sur `main` normalement (Push → CI/CD → + Pipelines, ou un simple commit). Le job `build-frontend` échouera ou + produira une image sans édition tant que `VITE_EDITION_ID` n'existe pas : + c'est attendu, on l'ignore pour l'instant. +2. Une fois `build-backend` et `deploy` passés, `db` et `backend` tournent + sur le VPS. + +## Étape 3 — créer l'édition de production + +```bash +ssh SSH_USER@SSH_HOST +cd /opt/benevoles +docker compose -f docker-compose.prod.yml run --rm backend \ + uv run python -m app.bootstrap \ + --name "Festival 2026" --start 2026-07-01 --end 2026-07-04 \ + --email coordination@mon-domaine.fr --password +``` + +La commande affiche l'UUID de l'édition créée. Récupérer aussi celui-ci à +tout moment avec : + +```bash +docker compose -f docker-compose.prod.yml exec -T db \ + psql -U benevoles -tAc "select id from edition order by created_at desc limit 1" +``` + +## Étape 4 — brancher le frontend sur cette édition + +Dans GitLab, ajouter la variable CI/CD `VITE_EDITION_ID` avec cet UUID, puis +relancer le pipeline sur `main` (bouton *Retry* sur le dernier pipeline, ou un +nouveau commit). Cette fois `build-frontend` construit une image qui pointe +sur la bonne édition, et `deploy` démarre le service `frontend`. + +`VITE_EDITION_ID` ne change plus ensuite : elle ne se touche à nouveau que +pour une nouvelle édition (festival de l'année suivante) qu'on voudrait +publier sur ce même domaine. + +## Étape 5 — le vhost Caddy + +Caddy tourne déjà sur ce serveur (service système) : ajouter le bloc +[`deploy/Caddyfile.benevoles`](./Caddyfile.benevoles) à son `Caddyfile` (ou au +fichier qu'il importe pour les sites), avec le vrai nom de domaine, puis : + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl reload caddy +``` + +Le DNS du domaine doit déjà pointer (enregistrement `A`/`AAAA`) vers ce VPS — +Caddy obtient et renouvelle seul le certificat HTTPS (Let's Encrypt) au +premier accès. + +Vérifier ensuite que `PUBLIC_BASE_URL` dans le `.env` du VPS (étape 1) +correspond bien à `https://` — c'est cette valeur qui est utilisée +dans les liens des emails envoyés aux bénévoles. + +## Déploiements suivants + +Un simple push sur `main` suffit : tests → build → déploiement → migration → +redémarrage, sans aucune intervention sur le VPS ni sur Caddy. diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 0513c5b..33441fc 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -3,7 +3,12 @@ name: benevoles # Déployé sur le VPS. Les variables sont lues depuis un fichier .env situé à # côté de ce fichier (non versionné) : POSTGRES_USER, POSTGRES_PASSWORD, # POSTGRES_DB, JWT_SECRET, PUBLIC_BASE_URL et les variables SMTP_* / EMAIL_ENABLED. -# BACKEND_IMAGE est exporté par le workflow de déploiement. +# BACKEND_IMAGE / FRONTEND_IMAGE sont exportées par le pipeline de déploiement +# (.gitlab-ci.yml). +# +# Les deux services ne publient que sur `127.0.0.1` : c'est le Caddy du VPS +# (hors de ce compose, voir deploy/README.md) qui reçoit le trafic public et +# reverse-proxy vers ces ports. services: db: @@ -22,7 +27,7 @@ services: retries: 10 backend: - image: ${BACKEND_IMAGE:-ghcr.io/OWNER/REPO-backend:latest} + image: ${BACKEND_IMAGE:-registry.gitlab.com/OWNER/REPO/backend:latest} environment: DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} ENVIRONMENT: production @@ -41,7 +46,15 @@ services: db: condition: service_healthy ports: - - "8000:8000" + - "127.0.0.1:8000:8000" + restart: unless-stopped + + frontend: + image: ${FRONTEND_IMAGE:-registry.gitlab.com/OWNER/REPO/frontend:latest} + depends_on: + - backend + ports: + - "127.0.0.1:8080:80" restart: unless-stopped volumes: diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..9601e77 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,28 @@ +# Image de production : build Vite statique, servi par nginx. +# +# `VITE_API_MODE` et `VITE_EDITION_ID` sont des variables **de build** (Vite +# les fige dans le bundle, il n'y a rien à configurer à l'exécution) : +# l'édition ciblée doit donc déjà exister côté backend avant de construire +# cette image. Voir deploy/README.md pour la procédure de première mise en +# service. + +FROM node:20-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . + +ARG VITE_API_MODE=http +ARG VITE_EDITION_ID +ENV VITE_API_MODE=$VITE_API_MODE +ENV VITE_EDITION_ID=$VITE_EDITION_ID + +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c1a6fed --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Fichiers hachés par Vite (contenu du nom = jamais modifiés une fois + # publiés) : cache long côté navigateur. + location /assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + # Application monopage (react-router-dom) : toute route inconnue du + # serveur retombe sur index.html, le routeur prend le relais côté client. + location / { + try_files $uri $uri/ /index.html; + } +}