feat(deploy): pipeline CD et composition Docker complete

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BAUeCFpDkRD6tU5wGsc1C
This commit is contained in:
David 2026-09-07 21:40:50 +02:00
parent b22f4e0b74
commit a19a90cea0
2 changed files with 198 additions and 111 deletions

View File

@ -1,40 +1,52 @@
name: CD Production name: CD Production
# Production pipeline — Hetzner k3s. # Pipeline de production — Hetzner k3s (infra/prod/).
# #
# SECURITY: Two mandatory gates before any production deployment: # Enchaînement : qualité → vérification → promotion/rebuild → déploiement → contrôle
# 1. quality-gate — lint + unit tests on the exact commit being deployed
# 2. verify-image — confirms preprod-SHA image EXISTS in registry,
# which proves this commit passed the full preprod
# pipeline (lint + unit + integration + docker build).
# If someone merges to main without going through preprod,
# this step fails and the deployment is blocked.
# #
# Flow: quality-gate → verify-image → promote → deploy → notify # TROIS RÈGLES STRUCTURANTES
# #
# Secrets required: # 1. Le BACKEND est PROMU depuis la preprod, jamais reconstruit.
# REGISTRY_TOKEN — Scaleway registry (read/write) # Promouvoir garantit que le binaire déployé en production est exactement
# HETZNER_KUBECONFIG — base64: cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0 # celui qui a passé la chaîne de preprod (lint, tests unitaires, tests
# PROD_BACKEND_URL — https://api.xpeditis.com # d'intégration, build). Un rebuild casserait cette garantie.
# PROD_FRONTEND_URL — https://app.xpeditis.com #
# DISCORD_WEBHOOK_URL # 2. Le FRONTEND est RECONSTRUIT pour la production.
# next.config.js fige NEXT_PUBLIC_API_URL au moment du build. Promouvoir
# l'image de preprod livrerait une application qui appelle
# api.preprod.xpeditis.com en production. C'est la raison pour laquelle ce
# workflow ne peut pas se contenter de re-taguer.
#
# 3. Le déploiement passe par SSH, pas par l'API Kubernetes.
# L'API k3s (6443) n'est ouverte qu'aux IP d'administration. Les runners
# GitHub n'ont pas d'IP fixe : le job ouvre le port 22 pour la seule IP du
# runner via un firewall Hetzner dédié, puis le referme systématiquement.
#
# Secrets et variables : voir infra/prod/env/github-secrets.md
on: on:
push: push:
branches: [main] branches: [main]
workflow_dispatch:
inputs:
tag:
description: "SHA court à déployer (laisser vide = HEAD de main)"
required: false
concurrency: concurrency:
group: cd-production group: cd-production
cancel-in-progress: false cancel-in-progress: false
permissions:
contents: read
env: env:
REGISTRY: rg.fr-par.scw.cloud/weworkstudio REGISTRY: rg.fr-par.scw.cloud/weworkstudio
NODE_VERSION: '20' NODE_VERSION: '20'
K8S_NAMESPACE: xpeditis-prod K8S_NAMESPACE: xpeditis-prod
jobs: jobs:
# ── 1. Quality Gate ────────────────────────────────────────────────── # ═══ 1. Qualité ══════════════════════════════════════════════════════════
# Runs on every prod deployment regardless of what happened in preprod.
backend-quality: backend-quality:
name: Backend — Lint name: Backend — Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -69,7 +81,7 @@ jobs:
- run: npm run type-check - run: npm run type-check
backend-tests: backend-tests:
name: Backend — Unit Tests name: Backend — Tests unitaires
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: backend-quality needs: backend-quality
defaults: defaults:
@ -86,7 +98,7 @@ jobs:
- run: npm test -- --passWithNoTests - run: npm test -- --passWithNoTests
frontend-tests: frontend-tests:
name: Frontend — Unit Tests name: Frontend — Tests unitaires
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: frontend-quality needs: frontend-quality
defaults: defaults:
@ -102,175 +114,248 @@ jobs:
- run: npm ci --legacy-peer-deps - run: npm ci --legacy-peer-deps
- run: npm test -- --passWithNoTests - run: npm test -- --passWithNoTests
# ── 2. Image Verification ──────────────────────────────────────────── # ═══ 2. Vérification de la provenance ════════════════════════════════════
# Checks that preprod-SHA tags exist for this EXACT commit. # Si l'image preprod-SHA n'existe pas, c'est que ce commit n'est jamais passé
# This is the security gate: if the preprod pipeline never ran for this # par la chaîne de preprod. Le déploiement est alors bloqué net.
# commit (or failed before the docker build step), this job fails and
# the deployment is fully blocked.
verify-image: verify-image:
name: Verify Preprod Image Exists name: Vérifier l'image de preprod
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend-tests, frontend-tests] needs: [backend-tests, frontend-tests]
outputs: outputs:
sha: ${{ steps.sha.outputs.short }} sha: ${{ steps.sha.outputs.short }}
steps: steps:
- name: Short SHA - name: SHA court
id: sha id: sha
run: echo "short=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT run: |
RAW="${{ github.event.inputs.tag }}"
[ -n "$RAW" ] || RAW="${{ github.sha }}"
echo "short=$(echo "$RAW" | cut -c1-7)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: nologin username: nologin
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: Check backend image preprod-SHA - name: Image backend preprod-SHA présente
run: | run: |
TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}" TAG="${{ env.REGISTRY }}/xpeditis-backend:preprod-${{ steps.sha.outputs.short }}"
echo "Verifying: $TAG"
docker buildx imagetools inspect "$TAG" || { docker buildx imagetools inspect "$TAG" || {
echo "" echo "::error::$TAG introuvable. Ce commit n'a pas été construit par la chaîne de preprod."
echo "BLOCKED: Image $TAG not found in registry." echo "Fusionnez d'abord sur preprod et attendez que le pipeline passe au vert."
echo "This commit was not built by the preprod pipeline."
echo "Merge to preprod first and wait for the full pipeline to succeed."
exit 1 exit 1
} }
- name: Check frontend image preprod-SHA - name: Image log-exporter preprod-SHA présente
run: | run: |
TAG="${{ env.REGISTRY }}/xpeditis-frontend:preprod-${{ steps.sha.outputs.short }}" TAG="${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${{ steps.sha.outputs.short }}"
echo "Verifying: $TAG"
docker buildx imagetools inspect "$TAG" || { docker buildx imagetools inspect "$TAG" || {
echo "" echo "::error::$TAG introuvable."
echo "BLOCKED: Image $TAG not found in registry."
echo "This commit was not built by the preprod pipeline."
echo "Merge to preprod first and wait for the full pipeline to succeed."
exit 1 exit 1
} }
# ── 3. Promote Images ──────────────────────────────────────────────── # ═══ 3a. Promotion du backend (aucun rebuild) ════════════════════════════
# Re-tags preprod-SHA → latest + prod-SHA within Scaleway. promote-backend:
# No rebuild. No layer transfer. Manifest-level operation only. name: Promouvoir le backend
promote-images:
name: Promote Images (preprod-SHA → prod)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: verify-image needs: verify-image
steps: steps:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3 - uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: nologin username: nologin
password: ${{ secrets.REGISTRY_TOKEN }} password: ${{ secrets.REGISTRY_TOKEN }}
- name: preprod-SHA → prod-SHA
- name: Promote backend
run: | run: |
SHA="${{ needs.verify-image.outputs.sha }}" SHA="${{ needs.verify-image.outputs.sha }}"
# Opération au niveau du manifeste : aucune couche n'est retransférée,
# le condensat de l'image reste identique à celui validé en preprod.
docker buildx imagetools create \ docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-backend:latest \
--tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \ --tag ${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA} \
--tag ${{ env.REGISTRY }}/xpeditis-backend:latest \
${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA} ${{ env.REGISTRY }}/xpeditis-backend:preprod-${SHA}
echo "Backend promoted: preprod-${SHA} → latest + prod-${SHA}"
- name: Promote frontend
run: |
SHA="${{ needs.verify-image.outputs.sha }}"
docker buildx imagetools create \ docker buildx imagetools create \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:latest \ --tag ${{ env.REGISTRY }}/xpeditis-log-exporter:prod-${SHA} \
--tag ${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA} \ --tag ${{ env.REGISTRY }}/xpeditis-log-exporter:latest \
${{ env.REGISTRY }}/xpeditis-frontend:preprod-${SHA} ${{ env.REGISTRY }}/xpeditis-log-exporter:preprod-${SHA}
echo "Frontend promoted: preprod-${SHA} → latest + prod-${SHA}"
# ── 4. Deploy to k3s ───────────────────────────────────────────────── # ═══ 3b. Reconstruction du frontend avec les URLs de production ══════════
deploy: build-frontend:
name: Deploy to Production (k3s) name: Reconstruire le frontend (URLs de production)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [verify-image, promote-images] needs: verify-image
steps:
- uses: actions/checkout@v4
with:
# On construit EXACTEMENT le commit vérifié, pas HEAD.
ref: ${{ github.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.REGISTRY_TOKEN }}
- uses: docker/build-push-action@v5
with:
context: ./apps/frontend
file: ./apps/frontend/Dockerfile
push: true
platforms: linux/amd64
tags: |
${{ env.REGISTRY }}/xpeditis-frontend:prod-${{ needs.verify-image.outputs.sha }}
${{ env.REGISTRY }}/xpeditis-frontend:latest
cache-from: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache-prod
cache-to: type=registry,ref=${{ env.REGISTRY }}/xpeditis-frontend:buildcache-prod,mode=max
build-args: |
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL_PROD }}
NEXT_PUBLIC_APP_URL=${{ secrets.NEXT_PUBLIC_APP_URL_PROD }}
- name: Contrôle — l'URL de preprod ne doit pas figurer dans le bundle
run: |
IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${{ needs.verify-image.outputs.sha }}"
CID=$(docker create "$IMAGE")
docker cp "$CID:/app/.next" /tmp/next-check 2>/dev/null || true
docker rm "$CID" >/dev/null
if grep -rq "api.preprod.xpeditis.com" /tmp/next-check 2>/dev/null; then
echo "::error::L'URL de preprod est figée dans le bundle de production."
echo "Vérifiez le secret NEXT_PUBLIC_API_URL_PROD."
exit 1
fi
echo "Aucune URL de preprod dans le bundle."
# ═══ 4. Déploiement ══════════════════════════════════════════════════════
deploy:
name: Déployer en production
runs-on: ubuntu-latest
needs: [verify-image, promote-backend, build-frontend]
# Environnement protégé : activez « Required reviewers » pour exiger une
# validation humaine avant toute mise en production.
environment: environment:
name: production name: production
url: https://app.xpeditis.com url: https://app.xpeditis.com
steps: steps:
- name: Configure kubectl - uses: actions/checkout@v4
run: |
mkdir -p ~/.kube
echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
kubectl cluster-info
kubectl get nodes -o wide
- name: Deploy backend - name: Installer le client Hetzner
id: deploy-backend run: |
curl -fsSL https://github.com/hetznercloud/cli/releases/download/v1.49.0/hcloud-linux-amd64.tar.gz \
| tar -xz -C /tmp hcloud
sudo install -m 0755 /tmp/hcloud /usr/local/bin/hcloud
hcloud version
- name: Ouvrir le port 22 pour l'IP de ce runner
env:
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN_CICD }}
run: |
RUNNER_IP="$(curl -fsS --max-time 10 https://ifconfig.me)"
echo "IP du runner : ${RUNNER_IP}"
cat > /tmp/fw-open.json <<JSON
[{
"direction": "in",
"protocol": "tcp",
"port": "22",
"source_ips": ["${RUNNER_IP}/32"],
"description": "GitHub Actions run ${{ github.run_id }}"
}]
JSON
hcloud firewall replace-rules "${{ vars.HCLOUD_CICD_FIREWALL }}" --rules-file /tmp/fw-open.json
- name: Préparer SSH
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
# Empreinte épinglée : un détournement DNS ou BGP ne peut pas
# rediriger le déploiement vers une machine tierce.
echo "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Synchroniser infra/prod sur le serveur
run: |
rsync -az --delete \
--exclude '.terraform' --exclude '*.tfstate*' --exclude '*.tfvars' \
-e "ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519" \
infra/prod/ \
"${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}:/opt/xpeditis/infra-prod/"
- name: Déployer
id: deploy
run: | run: |
SHA="${{ needs.verify-image.outputs.sha }}" SHA="${{ needs.verify-image.outputs.sha }}"
IMAGE="${{ env.REGISTRY }}/xpeditis-backend:prod-${SHA}" ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
echo "Deploying: $IMAGE" "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \
kubectl set image deployment/xpeditis-backend backend="$IMAGE" -n ${{ env.K8S_NAMESPACE }} "deploy prod-${SHA}"
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=300s
echo "Backend rollout complete."
- name: Deploy frontend - name: Tests de fumée depuis l'extérieur
id: deploy-frontend env:
PROD_API_URL: ${{ vars.PROD_API_URL }}
PROD_APP_URL: ${{ vars.PROD_APP_URL }}
run: bash infra/prod/scripts/smoke-test.sh
- name: Retour arrière si le déploiement a échoué
if: failure() && steps.deploy.conclusion == 'failure'
run: | run: |
SHA="${{ needs.verify-image.outputs.sha }}" ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
IMAGE="${{ env.REGISTRY }}/xpeditis-frontend:prod-${SHA}" "${{ secrets.PROD_SSH_USER }}@${{ secrets.PROD_SSH_HOST }}" \
echo "Deploying: $IMAGE" "rollback" || true
kubectl set image deployment/xpeditis-frontend frontend="$IMAGE" -n ${{ env.K8S_NAMESPACE }}
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=300s
echo "Frontend rollout complete."
- name: Auto-rollback on deployment failure - name: Refermer le firewall
if: failure() # `always()` : la fenêtre d'exposition se referme même si le
# déploiement a échoué, si le job a été annulé ou s'il a expiré.
if: always()
env:
HCLOUD_TOKEN: ${{ secrets.HCLOUD_TOKEN_CICD }}
run: | run: |
echo "Deployment failed — initiating rollback..." echo '[]' > /tmp/fw-close.json
kubectl rollout undo deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} hcloud firewall replace-rules "${{ vars.HCLOUD_CICD_FIREWALL }}" --rules-file /tmp/fw-close.json
kubectl rollout undo deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} echo "Firewall CI refermé."
kubectl rollout status deployment/xpeditis-backend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
kubectl rollout status deployment/xpeditis-frontend -n ${{ env.K8S_NAMESPACE }} --timeout=120s
echo "Rollback complete. Previous version is live."
# ── Notifications ──────────────────────────────────────────────────── - name: Effacer la clé SSH
if: always()
run: shred -u ~/.ssh/id_ed25519 2>/dev/null || rm -f ~/.ssh/id_ed25519
# ═══ 5. Notifications ════════════════════════════════════════════════════
notify-success: notify-success:
name: Notify Success name: Notifier le succès
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [verify-image, deploy] needs: [verify-image, deploy]
if: success() if: success()
steps: steps:
- run: | - run: |
curl -s -H "Content-Type: application/json" -d '{ curl -sf -H "Content-Type: application/json" -d '{
"embeds": [{ "embeds": [{
"title": "🚀 Production Deployed & Healthy", "title": "Production déployée et saine",
"color": 3066993, "color": 3066993,
"fields": [ "fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true}, {"name": "Auteur", "value": "${{ github.actor }}", "inline": true},
{"name": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true}, {"name": "Version", "value": "`prod-${{ needs.verify-image.outputs.sha }}`", "inline": true},
{"name": "Cluster", "value": "Hetzner k3s — `xpeditis-prod`", "inline": false}, {"name": "Cible", "value": "Hetzner k3s — xpeditis-prod", "inline": false},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false} {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}
], ],
"footer": {"text": "Xpeditis CI/CD • Production"} "footer": {"text": "Xpeditis CI/CD - Production"}
}] }]
}' ${{ secrets.DISCORD_WEBHOOK_URL }} }' ${{ secrets.DISCORD_WEBHOOK_URL }}
notify-failure: notify-failure:
name: Notify Failure name: Notifier l'échec
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-images, deploy] needs: [backend-quality, frontend-quality, backend-tests, frontend-tests, verify-image, promote-backend, build-frontend, deploy]
if: failure() if: failure()
steps: steps:
- run: | - run: |
curl -s -H "Content-Type: application/json" -d '{ curl -sf -H "Content-Type: application/json" -d '{
"content": "@here PRODUCTION PIPELINE FAILED", "content": "@here ECHEC DU PIPELINE DE PRODUCTION",
"embeds": [{ "embeds": [{
"title": "🔴 Production Pipeline Failed", "title": "Pipeline de production en échec",
"description": "Check the workflow for details. Auto-rollback was triggered if the failure was during deploy.", "description": "Un retour arrière a été tenté si l échec est survenu pendant le déploiement. Vérifiez l état réel avant toute nouvelle tentative.",
"color": 15158332, "color": 15158332,
"fields": [ "fields": [
{"name": "Author", "value": "${{ github.actor }}", "inline": true}, {"name": "Auteur", "value": "${{ github.actor }}", "inline": true},
{"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false}, {"name": "Workflow", "value": "[${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": false},
{"name": "Rollback", "value": "[Run rollback workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/rollback.yml)", "inline": false} {"name": "A vérifier", "value": "Le firewall CI est-il bien refermé ? `hcloud firewall describe xpeditis-prod-fw-cicd`", "inline": false}
], ],
"footer": {"text": "Xpeditis CI/CD • Production"} "footer": {"text": "Xpeditis CI/CD - Production"}
}] }]
}' ${{ secrets.DISCORD_WEBHOOK_URL }} }' ${{ secrets.DISCORD_WEBHOOK_URL }}

View File

@ -2,7 +2,8 @@
# Xpeditis — Full Dev Stack (infrastructure + app + logging) # Xpeditis — Full Dev Stack (infrastructure + app + logging)
# #
# Usage: # Usage:
# docker-compose -f docker-compose.full.yml up -d # docker network inspect xpeditis-network >/dev/null 2>&1 || docker network create xpeditis-network
# docker compose -f docker/docker-compose.full.yml up -d --build
# #
# Exposed ports: # Exposed ports:
# - Frontend: http://localhost:3000 # - Frontend: http://localhost:3000
@ -28,7 +29,7 @@ services:
POSTGRES_USER: xpeditis POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password POSTGRES_PASSWORD: xpeditis_dev_password
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U xpeditis"] test: ["CMD-SHELL", "pg_isready -U xpeditis -d xpeditis_dev"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@ -252,3 +253,4 @@ volumes:
networks: networks:
xpeditis-network: xpeditis-network:
name: xpeditis-network name: xpeditis-network
external: true