merge: integrer feat/mise-en-prod
This commit is contained in:
commit
7df9fd41c1
303
.github/workflows/cd-main.yml
vendored
303
.github/workflows/cd-main.yml
vendored
@ -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 }}
|
||||||
|
|||||||
@ -91,3 +91,27 @@ STRIPE_GOLD_MONTHLY_PRICE_ID=
|
|||||||
STRIPE_GOLD_YEARLY_PRICE_ID=
|
STRIPE_GOLD_YEARLY_PRICE_ID=
|
||||||
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=
|
STRIPE_PLATINIUM_MONTHLY_PRICE_ID=
|
||||||
STRIPE_PLATINIUM_YEARLY_PRICE_ID=
|
STRIPE_PLATINIUM_YEARLY_PRICE_ID=
|
||||||
|
|
||||||
|
# Premier administrateur (amorcage) - migration BootstrapAdminFromEnv
|
||||||
|
# En developpement, laissez vide : SeedTestUsers cree deja admin@xpeditis.com.
|
||||||
|
# En production, renseignez une adresse RELEVABLE : le compte est cree sans
|
||||||
|
# mot de passe utilisable et vous definissez le votre via "mot de passe oublie".
|
||||||
|
# BOOTSTRAP_ADMIN_EMAIL=
|
||||||
|
# BOOTSTRAP_ADMIN_FIRST_NAME=Admin
|
||||||
|
# BOOTSTRAP_ADMIN_LAST_NAME=Xpeditis
|
||||||
|
# BOOTSTRAP_ADMIN_ORG_NAME=Xpeditis
|
||||||
|
# BOOTSTRAP_ADMIN_ORG_STREET=A completer
|
||||||
|
# BOOTSTRAP_ADMIN_ORG_CITY=A completer
|
||||||
|
# BOOTSTRAP_ADMIN_ORG_POSTAL_CODE=00000
|
||||||
|
# BOOTSTRAP_ADMIN_ORG_COUNTRY=FR
|
||||||
|
# Facultatif : hash Argon2id, si SMTP n'est pas encore operationnel.
|
||||||
|
# Generer avec : node scripts/setup/generate-admin-hash.js
|
||||||
|
# Jamais un mot de passe en clair - la migration le refuse.
|
||||||
|
# BOOTSTRAP_ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# Force la neutralisation des comptes de demonstration hors production.
|
||||||
|
# FORCE_NEUTRALIZE_SEED_ACCOUNTS=true
|
||||||
|
|
||||||
|
# Trade assistant — server only. Empty key enables guided help only.
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=gpt-4.1-mini
|
||||||
|
|||||||
129
apps/backend/scripts/setup/generate-admin-hash.js
Normal file
129
apps/backend/scripts/setup/generate-admin-hash.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Génère un hash Argon2id pour BOOTSTRAP_ADMIN_PASSWORD_HASH.
|
||||||
|
*
|
||||||
|
* cd apps/backend && node scripts/setup/generate-admin-hash.js
|
||||||
|
*
|
||||||
|
* Le mot de passe est saisi sans écho et ne quitte jamais votre poste : ni
|
||||||
|
* argument de ligne de commande (visible dans `ps` et dans l'historique du
|
||||||
|
* shell), ni variable d'environnement, ni fichier temporaire.
|
||||||
|
*
|
||||||
|
* RAPPEL — le mode SANS mot de passe est préférable.
|
||||||
|
* Si votre chaîne SMTP fonctionne, ne renseignez que BOOTSTRAP_ADMIN_EMAIL :
|
||||||
|
* le compte est alors créé sans mot de passe utilisable et vous le définissez
|
||||||
|
* via « mot de passe oublié ». Aucun secret n'existe nulle part, il n'y a donc
|
||||||
|
* rien à faire fuiter. Ce script n'est utile que si vous devez pouvoir vous
|
||||||
|
* connecter avant que l'envoi de courriels ne soit opérationnel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const argon2 = require('argon2');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// Mêmes paramètres que auth.service.ts : un hash produit ici est vérifiable
|
||||||
|
// par l'application sans aucune adaptation.
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 65536, // 64 Mo
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_LENGTH = 16;
|
||||||
|
|
||||||
|
/** Saisie masquée sur un terminal ; lecture directe si l'entrée est redirigée. */
|
||||||
|
function readSecret(prompt) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
let data = '';
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
process.stdin.on('data', chunk => (data += chunk));
|
||||||
|
process.stdin.on('end', () => resolve(data.replace(/\r?\n$/, '')));
|
||||||
|
process.stdin.on('error', reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
const onKeypress = () => {
|
||||||
|
// Réécrit la ligne sans révéler la longueur de la saisie.
|
||||||
|
readline.clearLine(process.stdout, 0);
|
||||||
|
readline.cursorTo(process.stdout, 0);
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
process.stdin.on('data', onKeypress);
|
||||||
|
|
||||||
|
rl.question('', answer => {
|
||||||
|
process.stdin.removeListener('data', onKeypress);
|
||||||
|
rl.close();
|
||||||
|
process.stdout.write('\n');
|
||||||
|
resolve(answer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkStrength(password) {
|
||||||
|
const problems = [];
|
||||||
|
if (password.length < MIN_LENGTH) {
|
||||||
|
problems.push(`au moins ${MIN_LENGTH} caractères (${password.length} fournis)`);
|
||||||
|
}
|
||||||
|
if (!/[a-z]/.test(password)) problems.push('une minuscule');
|
||||||
|
if (!/[A-Z]/.test(password)) problems.push('une majuscule');
|
||||||
|
if (!/[0-9]/.test(password)) problems.push('un chiffre');
|
||||||
|
if (!/[^A-Za-z0-9]/.test(password)) problems.push('un caractère spécial');
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('');
|
||||||
|
console.log('Génération du hash Argon2id pour le premier administrateur.');
|
||||||
|
console.log('La saisie n’est pas affichée.');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const password = await readSecret('Mot de passe : ');
|
||||||
|
if (!password) {
|
||||||
|
console.error('Aucun mot de passe saisi.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
const confirmation = await readSecret('Confirmation : ');
|
||||||
|
if (confirmation !== password) {
|
||||||
|
console.error('Les deux saisies diffèrent.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = checkStrength(password);
|
||||||
|
if (problems.length > 0) {
|
||||||
|
console.error('');
|
||||||
|
console.error('Mot de passe refusé. Il manque : ' + problems.join(', ') + '.');
|
||||||
|
console.error('Ce compte a tous les droits sur la plateforme : générez plutôt une');
|
||||||
|
console.error('phrase longue et aléatoire depuis votre gestionnaire de mots de passe.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = await argon2.hash(password, ARGON2_OPTIONS);
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Hash à placer dans le Secret Kubernetes (jamais dans le ConfigMap) :');
|
||||||
|
console.log('');
|
||||||
|
console.log(' BOOTSTRAP_ADMIN_PASSWORD_HASH: ' + JSON.stringify(hash));
|
||||||
|
console.log('');
|
||||||
|
console.log(' cd infra/prod && sops k8s/base/03-secrets.sops.yaml');
|
||||||
|
console.log('');
|
||||||
|
console.log('Après votre première connexion :');
|
||||||
|
console.log(' 1. changez le mot de passe depuis l’interface ;');
|
||||||
|
console.log(' 2. retirez BOOTSTRAP_ADMIN_PASSWORD_HASH du Secret et réappliquez.');
|
||||||
|
console.log('');
|
||||||
|
console.log('Un hash reste attaquable hors ligne : il n’a plus aucune raison');
|
||||||
|
console.log('de rester stocké une fois le compte opérationnel.');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('Échec :', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -1,9 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Seed Test Users Migration
|
* Seed Test Users Migration
|
||||||
*
|
*
|
||||||
* Seeds test users for development and testing
|
* Comptes de test pour le developpement et la preprod.
|
||||||
* Password for all users: Password123!
|
* Mot de passe commun : Password123! (hash Argon2id ci-dessous)
|
||||||
* Hash generated with Argon2id
|
*
|
||||||
|
* NE S'EXECUTE JAMAIS EN PRODUCTION
|
||||||
|
* ---------------------------------
|
||||||
|
* Ce fichier contient un mot de passe en clair pour un compte ADMIN. Sur une
|
||||||
|
* base de production, l'appliquer creerait un administrateur aux identifiants
|
||||||
|
* publics, connus de quiconque a lu le depot. La garde NODE_ENV ci-dessous
|
||||||
|
* l'en empeche.
|
||||||
|
*
|
||||||
|
* Le corps de la migration a ete modifie apres son ecriture initiale, ce qui
|
||||||
|
* deroge a la regle "ne jamais modifier une migration appliquee". C'est sans
|
||||||
|
* consequence ici : TypeORM suit les migrations par NOM de classe et ne
|
||||||
|
* recalcule aucune empreinte. Les bases ou elle a deja tourne (dev, preprod) ne
|
||||||
|
* la rejouent pas et gardent leurs comptes de test ; seules les bases neuves
|
||||||
|
* voient la garde s'appliquer.
|
||||||
|
*
|
||||||
|
* Filet de securite pour les bases ou elle aurait deja tourne :
|
||||||
|
* migration 1756000000000-NeutralizeSeedAccountsInProduction.
|
||||||
|
* Creation d'un vrai administrateur : 1756000000001-BootstrapAdminFromEnv.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
@ -11,6 +28,14 @@ import { DEFAULT_ORG_ID } from '../seeds/test-organizations.seed';
|
|||||||
|
|
||||||
export class SeedTestUsers1730000000007 implements MigrationInterface {
|
export class SeedTestUsers1730000000007 implements MigrationInterface {
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.log(
|
||||||
|
'SeedTestUsers ignore : NODE_ENV=production. ' +
|
||||||
|
'Utilisez BOOTSTRAP_ADMIN_EMAIL pour creer le premier administrateur.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use fixed organization ID from seed
|
// Use fixed organization ID from seed
|
||||||
const organizationId = DEFAULT_ORG_ID;
|
const organizationId = DEFAULT_ORG_ID;
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Neutralise les comptes de démonstration en production.
|
||||||
|
*
|
||||||
|
* POURQUOI
|
||||||
|
* --------
|
||||||
|
* La migration 1730000000007-SeedTestUsers insère trois comptes dont le mot de
|
||||||
|
* passe (`Password123!`) est écrit en clair dans le dépôt, dont un ADMIN.
|
||||||
|
* Sur une base de production neuve, appliquer les migrations créait donc un
|
||||||
|
* administrateur aux identifiants publics.
|
||||||
|
*
|
||||||
|
* SeedTestUsers ne s'exécute désormais plus en production (garde ajoutée dans
|
||||||
|
* cette même migration). Ce filet de sécurité couvre les cas restants :
|
||||||
|
* - une base de production migrée avant l'ajout de la garde ;
|
||||||
|
* - un environnement où NODE_ENV n'était pas correctement positionné ;
|
||||||
|
* - une restauration à partir d'une sauvegarde antérieure.
|
||||||
|
*
|
||||||
|
* Les lignes ne sont PAS supprimées : `audit_logs` et d'autres tables peuvent y
|
||||||
|
* référer, et une suppression en cascade ferait plus de dégâts que de bien.
|
||||||
|
* Les comptes sont renommés (ce qui libère `admin@xpeditis.com` pour votre vrai
|
||||||
|
* compte), rendus impossibles à authentifier, et désactivés.
|
||||||
|
*
|
||||||
|
* Idempotente : une seconde exécution ne trouve plus rien à faire.
|
||||||
|
*
|
||||||
|
* En développement et en preprod, cette migration ne fait rien — les comptes de
|
||||||
|
* test restent utilisables. Pour l'y forcer malgré tout :
|
||||||
|
* FORCE_NEUTRALIZE_SEED_ACCOUNTS=true
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
|
||||||
|
/** Paramètres Argon2id du projet (cf. auth.service.ts). */
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 65536,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SEED_ACCOUNTS = ['admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produit un hash Argon2id valide d'un secret aléatoire immédiatement perdu.
|
||||||
|
*
|
||||||
|
* Un hash *syntaxiquement valide* est indispensable : `auth.service.ts` appelle
|
||||||
|
* `argon2.verify()` sans try/catch, et une chaîne malformée lèverait une
|
||||||
|
* exception — donc un 500 au lieu du 401 attendu.
|
||||||
|
*/
|
||||||
|
async function unusablePasswordHash(): Promise<string> {
|
||||||
|
return argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NeutralizeSeedAccountsInProduction1756000000000 implements MigrationInterface {
|
||||||
|
name = 'NeutralizeSeedAccountsInProduction1756000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const forced = process.env.FORCE_NEUTRALIZE_SEED_ACCOUNTS === 'true';
|
||||||
|
|
||||||
|
if (!isProduction && !forced) {
|
||||||
|
console.log('[neutralisation] NODE_ENV != production : comptes de démonstration conservés.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Array<{ id: string; email: string }> = await queryRunner.query(
|
||||||
|
`SELECT "id", "email" FROM "users" WHERE "email" = ANY($1)`,
|
||||||
|
[SEED_ACCOUNTS]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('[neutralisation] Aucun compte de démonstration présent.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Le nouveau libellé respecte la contrainte chk_users_email
|
||||||
|
// (LOWER(email) = email) : les UUID sont en minuscules.
|
||||||
|
const disabledEmail = `seed-disabled-${String(row.id).slice(0, 8)}@invalid.local`;
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "users"
|
||||||
|
SET "email" = $1,
|
||||||
|
"password_hash" = $2,
|
||||||
|
"is_active" = false,
|
||||||
|
"updated_at" = NOW()
|
||||||
|
WHERE "id" = $3`,
|
||||||
|
[disabledEmail, await unusablePasswordHash(), row.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[neutralisation] ${row.email} -> ${disabledEmail} (désactivé)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contrôle explicite : la migration échoue plutôt que de laisser croire
|
||||||
|
// que le nettoyage a eu lieu.
|
||||||
|
const remaining: Array<{ n: number }> = await queryRunner.query(
|
||||||
|
`SELECT count(*)::int AS n FROM "users" WHERE "email" = ANY($1)`,
|
||||||
|
[SEED_ACCOUNTS]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remaining[0].n > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Neutralisation incomplète : ${remaining[0].n} compte(s) de démonstration subsistent.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[neutralisation] ${rows.length} compte(s) neutralisé(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Volontairement sans effet.
|
||||||
|
//
|
||||||
|
// Restaurer des comptes dont le mot de passe est public serait une
|
||||||
|
// régression de sécurité déclenchée par un simple `migration:revert`.
|
||||||
|
// Si vous avez réellement besoin des comptes de démonstration, recréez-les
|
||||||
|
// dans un environnement non productif.
|
||||||
|
console.log('[neutralisation] down() sans effet — par conception.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Crée le premier administrateur à partir de l'environnement.
|
||||||
|
*
|
||||||
|
* Remplace le compte `admin@xpeditis.com / Password123!` de la migration de
|
||||||
|
* démonstration : on garde la commodité (une base neuve arrive avec un
|
||||||
|
* administrateur utilisable) sans le mot de passe public.
|
||||||
|
*
|
||||||
|
* DEUX MODES
|
||||||
|
* ----------
|
||||||
|
*
|
||||||
|
* 1. SANS MOT DE PASSE — recommandé.
|
||||||
|
* BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr
|
||||||
|
*
|
||||||
|
* Le compte est créé avec un hash Argon2id d'un secret aléatoire
|
||||||
|
* immédiatement perdu : personne, pas même vous, ne peut s'y connecter.
|
||||||
|
* Vous définissez votre mot de passe via « mot de passe oublié », qui envoie
|
||||||
|
* un jeton à usage unique, valable 1 heure, stocké haché en base.
|
||||||
|
*
|
||||||
|
* Aucun secret n'existe donc nulle part : ni dans Git, ni dans le Secret
|
||||||
|
* Kubernetes, ni dans l'historique du shell, ni dans les journaux de
|
||||||
|
* migration. C'est la seule variante où il n'y a rien à faire fuiter.
|
||||||
|
* Effet de bord utile : la réception du courriel prouve que la chaîne SMTP
|
||||||
|
* fonctionne.
|
||||||
|
*
|
||||||
|
* 2. AVEC UN HASH PRÉ-CALCULÉ — si SMTP n'est pas encore opérationnel.
|
||||||
|
* BOOTSTRAP_ADMIN_EMAIL=vous@votredomaine.fr
|
||||||
|
* BOOTSTRAP_ADMIN_PASSWORD_HASH=$argon2id$v=19$m=65536,t=3,p=4$...
|
||||||
|
*
|
||||||
|
* Le hash se génère hors ligne :
|
||||||
|
* node apps/backend/scripts/setup/generate-admin-hash.js
|
||||||
|
* Le mot de passe en clair ne quitte jamais votre poste. Le hash, lui, reste
|
||||||
|
* sensible (attaque hors ligne possible) : utilisez un mot de passe long et
|
||||||
|
* aléatoire, changez-le après la première connexion, puis retirez la
|
||||||
|
* variable du Secret.
|
||||||
|
*
|
||||||
|
* GARDE-FOUS
|
||||||
|
* ----------
|
||||||
|
* - Sans BOOTSTRAP_ADMIN_EMAIL, la migration ne fait rien.
|
||||||
|
* - S'il existe déjà un ADMIN actif, la migration ne fait rien : elle ne peut
|
||||||
|
* donc pas créer un second administrateur à votre insu lors d'un déploiement
|
||||||
|
* ultérieur.
|
||||||
|
* - Si un compte porte déjà cette adresse, il est promu ADMIN sans que son
|
||||||
|
* mot de passe ne soit touché.
|
||||||
|
* - Un mot de passe en clair passé par erreur dans
|
||||||
|
* BOOTSTRAP_ADMIN_PASSWORD_HASH est refusé : la migration échoue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
|
||||||
|
/** Paramètres Argon2id du projet (cf. auth.service.ts). */
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 65536,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function env(name: string, fallback = ''): string {
|
||||||
|
return (process.env[name] ?? fallback).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BootstrapAdminFromEnv1756000000001 implements MigrationInterface {
|
||||||
|
name = 'BootstrapAdminFromEnv1756000000001';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const email = env('BOOTSTRAP_ADMIN_EMAIL').toLowerCase();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.log(
|
||||||
|
'[amorçage admin] BOOTSTRAP_ADMIN_EMAIL absent : aucun administrateur créé. ' +
|
||||||
|
'Inscrivez-vous par l’interface puis promouvez le compte en base.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EMAIL_PATTERN.test(email)) {
|
||||||
|
throw new Error(`[amorçage admin] BOOTSTRAP_ADMIN_EMAIL invalide : "${email}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ne jamais créer un second administrateur silencieusement.
|
||||||
|
const activeAdmins: Array<{ n: number }> = await queryRunner.query(
|
||||||
|
`SELECT count(*)::int AS n FROM "users" WHERE "role" = 'ADMIN' AND "is_active" = true`
|
||||||
|
);
|
||||||
|
if (activeAdmins[0].n > 0) {
|
||||||
|
console.log(
|
||||||
|
`[amorçage admin] ${activeAdmins[0].n} administrateur(s) actif(s) déjà présent(s) : rien à faire.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Compte déjà existant : promotion, sans toucher au mot de passe ------
|
||||||
|
const existing: Array<{ id: string }> = await queryRunner.query(
|
||||||
|
`SELECT "id" FROM "users" WHERE "email" = $1`,
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "users"
|
||||||
|
SET "role" = 'ADMIN', "is_active" = true, "updated_at" = NOW()
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[existing[0].id]
|
||||||
|
);
|
||||||
|
console.log(`[amorçage admin] Compte existant ${email} promu ADMIN (mot de passe inchangé).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Organisation de rattachement ---------------------------------------
|
||||||
|
// users.organization_id est NOT NULL avec clé étrangère : il faut une
|
||||||
|
// organisation avant de pouvoir créer l'administrateur.
|
||||||
|
const orgName = env('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis');
|
||||||
|
const orgCountry = env('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase();
|
||||||
|
|
||||||
|
if (!/^[A-Z]{2}$/.test(orgCountry)) {
|
||||||
|
throw new Error(
|
||||||
|
`[amorçage admin] BOOTSTRAP_ADMIN_ORG_COUNTRY doit être un code ISO à 2 lettres, reçu "${orgCountry}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const org: Array<{ id: string }> = await queryRunner.query(
|
||||||
|
`INSERT INTO "organizations"
|
||||||
|
("name", "type", "address_street", "address_city", "address_postal_code", "address_country")
|
||||||
|
VALUES ($1, 'FREIGHT_FORWARDER', $2, $3, $4, $5)
|
||||||
|
ON CONFLICT ("name") DO UPDATE SET "updated_at" = NOW()
|
||||||
|
RETURNING "id"`,
|
||||||
|
[
|
||||||
|
orgName,
|
||||||
|
env('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer'),
|
||||||
|
env('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer'),
|
||||||
|
env('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000'),
|
||||||
|
orgCountry,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const organizationId = org[0].id;
|
||||||
|
|
||||||
|
// --- Mot de passe --------------------------------------------------------
|
||||||
|
const providedHash = env('BOOTSTRAP_ADMIN_PASSWORD_HASH');
|
||||||
|
let passwordHash: string;
|
||||||
|
let mode: string;
|
||||||
|
|
||||||
|
if (providedHash) {
|
||||||
|
if (!providedHash.startsWith('$argon2')) {
|
||||||
|
throw new Error(
|
||||||
|
'[amorçage admin] BOOTSTRAP_ADMIN_PASSWORD_HASH doit contenir un hash Argon2 ' +
|
||||||
|
'(commençant par "$argon2"), jamais un mot de passe en clair. ' +
|
||||||
|
'Générez-le avec scripts/setup/generate-admin-hash.js.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
passwordHash = providedHash;
|
||||||
|
mode = 'hash fourni par l’environnement';
|
||||||
|
} else {
|
||||||
|
// Hash d'un secret aléatoire immédiatement perdu : le compte existe, il
|
||||||
|
// est actif, mais aucun mot de passe ne peut y correspondre.
|
||||||
|
passwordHash = await argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS);
|
||||||
|
mode = 'aucun mot de passe — à définir via « mot de passe oublié »';
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "users"
|
||||||
|
("organization_id", "email", "password_hash", "role",
|
||||||
|
"first_name", "last_name", "is_email_verified", "is_active")
|
||||||
|
VALUES ($1, $2, $3, 'ADMIN', $4, $5, true, true)`,
|
||||||
|
[
|
||||||
|
organizationId,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
env('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin'),
|
||||||
|
env('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`[amorçage admin] Administrateur ${email} créé (${mode}).`);
|
||||||
|
if (!providedHash) {
|
||||||
|
console.log(
|
||||||
|
'[amorçage admin] Étape suivante : POST /api/v1/auth/forgot-password avec cette adresse, ' +
|
||||||
|
'puis suivez le lien reçu par courriel pour définir le mot de passe.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Volontairement sans effet : supprimer l'unique administrateur d'une
|
||||||
|
// production sur un `migration:revert` serait pire que le problème résolu.
|
||||||
|
console.log('[amorçage admin] down() sans effet — par conception.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -40,14 +40,48 @@ Documentation complète de la plateforme B2B SaaS de réservation de fret mariti
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Déploiement
|
## Mise en production
|
||||||
|
|
||||||
|
**[mise-en-prod/](mise-en-prod/README.md)** — procédure complète, pas à pas, pour
|
||||||
|
ouvrir la plateforme au public sur Hetzner. C'est le point d'entrée à suivre
|
||||||
|
pour un déploiement réel. Les fichiers correspondants sont dans
|
||||||
|
[`infra/prod/`](../infra/prod/README.md).
|
||||||
|
|
||||||
|
| Étape | Fichier |
|
||||||
|
|---|---|
|
||||||
|
| Index, chronologie, blocages identifiés | [mise-en-prod/README.md](mise-en-prod/README.md) |
|
||||||
|
| Prérequis (comptes, outils, clés) | [mise-en-prod/01-prerequis.md](mise-en-prod/01-prerequis.md) |
|
||||||
|
| Provisioning Hetzner (Terraform) | [mise-en-prod/02-provisioning-hetzner.md](mise-en-prod/02-provisioning-hetzner.md) |
|
||||||
|
| Durcissement des serveurs | [mise-en-prod/03-durcissement-serveurs.md](mise-en-prod/03-durcissement-serveurs.md) |
|
||||||
|
| Nœud de données (PostgreSQL, Redis) | [mise-en-prod/04-noeud-donnees.md](mise-en-prod/04-noeud-donnees.md) |
|
||||||
|
| Cluster k3s | [mise-en-prod/05-cluster-k3s.md](mise-en-prod/05-cluster-k3s.md) |
|
||||||
|
| Secrets (SOPS + age) | [mise-en-prod/06-secrets-sops.md](mise-en-prod/06-secrets-sops.md) |
|
||||||
|
| Stockage objet | [mise-en-prod/07-stockage-objet-s3.md](mise-en-prod/07-stockage-objet-s3.md) |
|
||||||
|
| DNS, TLS, Cloudflare | [mise-en-prod/08-dns-tls-cloudflare.md](mise-en-prod/08-dns-tls-cloudflare.md) |
|
||||||
|
| Déploiement applicatif | [mise-en-prod/09-deploiement-application.md](mise-en-prod/09-deploiement-application.md) |
|
||||||
|
| CI/CD GitHub Actions | [mise-en-prod/10-cicd-github-actions.md](mise-en-prod/10-cicd-github-actions.md) |
|
||||||
|
| Observabilité | [mise-en-prod/11-observabilite.md](mise-en-prod/11-observabilite.md) |
|
||||||
|
| Sauvegardes et restauration | [mise-en-prod/12-sauvegardes-restauration.md](mise-en-prod/12-sauvegardes-restauration.md) |
|
||||||
|
| Sécurité | [mise-en-prod/13-securite-durcissement.md](mise-en-prod/13-securite-durcissement.md) |
|
||||||
|
| Runbook de mise en ligne | [mise-en-prod/14-runbook-go-live.md](mise-en-prod/14-runbook-go-live.md) |
|
||||||
|
| Exploitation et incidents | [mise-en-prod/15-exploitation-incidents.md](mise-en-prod/15-exploitation-incidents.md) |
|
||||||
|
| RGPD et conformité | [mise-en-prod/16-rgpd-conformite.md](mise-en-prod/16-rgpd-conformite.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement — autres environnements
|
||||||
|
|
||||||
| Sujet | Fichier |
|
| Sujet | Fichier |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Portainer / Docker Swarm | [deployment/portainer.md](deployment/portainer.md) |
|
| Preprod : Portainer / Docker Swarm | [deployment/portainer.md](deployment/portainer.md) |
|
||||||
| Hetzner / Kubernetes | [deployment/hetzner/README.md](deployment/hetzner/README.md) |
|
| Étude Hetzner / Kubernetes (antérieure) | [deployment/hetzner/README.md](deployment/hetzner/README.md) |
|
||||||
| Stripe (paiements) | [deployment/STRIPE_SETUP.md](deployment/STRIPE_SETUP.md) |
|
| Stripe (paiements) | [deployment/STRIPE_SETUP.md](deployment/STRIPE_SETUP.md) |
|
||||||
|
|
||||||
|
> `deployment/hetzner/` est l'étude de cadrage qui a précédé la mise en œuvre.
|
||||||
|
> Elle reste utile pour comprendre les arbitrages, mais **la procédure à suivre
|
||||||
|
> est `mise-en-prod/`**, seule alignée sur les fichiers réellement livrés dans
|
||||||
|
> `infra/prod/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|||||||
163
docs/mise-en-prod/01-prerequis.md
Normal file
163
docs/mise-en-prod/01-prerequis.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# 01 — Prérequis
|
||||||
|
|
||||||
|
**Durée : 2 à 3 h.** Rien de technique ici, mais tout bloque si un élément
|
||||||
|
manque au moment où vous en avez besoin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Comptes à créer ou vérifier
|
||||||
|
|
||||||
|
| Service | Rôle | À faire | Coût |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Hetzner Cloud** | Serveurs, réseau, firewalls, volume | Créer un **projet dédié** `xpeditis-prod`, séparé de la preprod. Activer la **2FA**. | ~50 €/mois |
|
||||||
|
| **Hetzner Storage Box** | Seconde copie des dumps | Commander une **BX11** (1 To). Commande séparée de Hetzner Cloud. | 3,90 €/mois |
|
||||||
|
| **Hetzner Object Storage** | Documents + archives WAL-G | Activer dans le projet, région `fsn1`. | ~6 €/mois |
|
||||||
|
| **Cloudflare** | DNS, WAF, anti-DDoS | Zone `xpeditis.com` déléguée. Plan Free suffisant. **2FA obligatoire.** | 0 € |
|
||||||
|
| **Registrar du domaine** | `xpeditis.com` | Pointer les serveurs de noms vers Cloudflare. Activer le **verrou de transfert**. | ~10 €/an |
|
||||||
|
| **Scaleway Container Registry** | Images Docker | Déjà utilisé par la preprod. Vérifier que `REGISTRY_TOKEN` est encore valide. | ~1 €/mois |
|
||||||
|
| **Brevo** | E-mails transactionnels | Créer une **nouvelle clé SMTP de production** (celle de preprod est compromise). Vérifier le domaine expéditeur. | 0 → 19 €/mois |
|
||||||
|
| **Stripe** | Paiements | Passer le compte en **mode Live**. Récupérer `sk_live_…`, créer le webhook de production, noter les 6 identifiants de tarif. | % du volume |
|
||||||
|
| **Sentry** | Erreurs applicatives | Projet `xpeditis-prod` distinct de la preprod. | 0 → 26 €/mois |
|
||||||
|
| **Pappers** | Vérification SIRET | Clé API. Facultatif : sans clé, la vérification est simplement ignorée. | ~3 €/mois |
|
||||||
|
| **Discord** | Alertes et déploiements | Deux webhooks : `#deploiements` et `#alertes`. | 0 € |
|
||||||
|
| **healthchecks.io** ou **BetterStack** | Surveillance externe | Un *heartbeat* pour les sauvegardes, un contrôle d'uptime sur `https://app.xpeditis.com`. | 0 € |
|
||||||
|
|
||||||
|
> **Pourquoi un projet Hetzner séparé** — le token API est valable pour tout un
|
||||||
|
> projet. Un token de preprod compromis ne doit pas pouvoir détruire la
|
||||||
|
> production. La séparation est aussi ce qui isole les deux réseaux privés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Décisions à trancher maintenant
|
||||||
|
|
||||||
|
### 2.1 Adresse e-mail d'exploitation
|
||||||
|
|
||||||
|
Une adresse **relevée** est nécessaire pour :
|
||||||
|
- Let's Encrypt (avertissements d'expiration si le renouvellement casse) ;
|
||||||
|
- Hetzner (incidents, maintenances) ;
|
||||||
|
- Cloudflare et le registrar.
|
||||||
|
|
||||||
|
Recommandé : `ops@xpeditis.com`, redirigée vers votre boîte personnelle.
|
||||||
|
Remplacez `ops@xpeditis.com` dans `infra/prod/k8s/cluster/cluster-issuer.yaml`
|
||||||
|
si vous choisissez autre chose.
|
||||||
|
|
||||||
|
### 2.2 IP d'administration
|
||||||
|
|
||||||
|
Terraform refuse `0.0.0.0/0` pour SSH. Il vous faut une IP publique stable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://ifconfig.me
|
||||||
|
```
|
||||||
|
|
||||||
|
- **IP fixe** (fibre pro, bureau) → parfait.
|
||||||
|
- **IP dynamique** → deux options :
|
||||||
|
- relancer `terraform apply` quand elle change (acceptable si c'est rare) ;
|
||||||
|
- passer par un VPN à IP fixe (Mullvad, un petit serveur Hetzner CX22 à 4 €).
|
||||||
|
|
||||||
|
### 2.3 Noms de domaine
|
||||||
|
|
||||||
|
La configuration livrée suppose :
|
||||||
|
|
||||||
|
| Domaine | Sert |
|
||||||
|
|---|---|
|
||||||
|
| `xpeditis.com`, `www.xpeditis.com` | vitrine (pages publiques du frontend) |
|
||||||
|
| `app.xpeditis.com` | application (origine des cookies d'authentification) |
|
||||||
|
| `api.xpeditis.com` | API |
|
||||||
|
| `grafana.xpeditis.com` | supervision |
|
||||||
|
|
||||||
|
Pour un autre découpage, modifiez `k8s/base/02-configmap-backend.yaml`
|
||||||
|
(`APP_URL`, `CORS_ORIGIN`, `COOKIE_DOMAIN`) **et** `k8s/base/09-ingress.yaml`.
|
||||||
|
|
||||||
|
> `COOKIE_DOMAIN=.xpeditis.com` (avec le point initial) est **indispensable** :
|
||||||
|
> l'API pose le cookie sur `api.xpeditis.com`, le middleware Next.js le lit sur
|
||||||
|
> `app.xpeditis.com`. Sans le point, la connexion réussit mais l'utilisateur est
|
||||||
|
> renvoyé sur `/login` — le symptôme est déroutant, la cause est ici.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Outils sur votre poste
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install terraform kubectl sops age hcloud jq rsync
|
||||||
|
brew install --cask docker # pour construire des images localement
|
||||||
|
|
||||||
|
# Vérification
|
||||||
|
terraform version # >= 1.6
|
||||||
|
kubectl version --client
|
||||||
|
sops --version # >= 3.8
|
||||||
|
age --version
|
||||||
|
hcloud version
|
||||||
|
jq --version
|
||||||
|
```
|
||||||
|
|
||||||
|
`shellcheck` est facultatif mais utilisé par `make validate` :
|
||||||
|
```bash
|
||||||
|
brew install shellcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Clés SSH
|
||||||
|
|
||||||
|
Trois clés distinctes, jamais interchangeables :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Administration (vous, sur les deux serveurs)
|
||||||
|
ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod
|
||||||
|
|
||||||
|
# 2. Déploiement CI (GitHub Actions → app-01, restreinte à un script)
|
||||||
|
ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ~/.ssh/xpeditis_ci
|
||||||
|
|
||||||
|
# 3. Storage Box (db-01 → Storage Box, pour les dumps)
|
||||||
|
ssh-keygen -t ed25519 -a 100 -C "xpeditis-storagebox" -f ~/.ssh/xpeditis_storagebox
|
||||||
|
```
|
||||||
|
|
||||||
|
Protégez la clé d'administration par une phrase de passe. Celles de la CI et de
|
||||||
|
la Storage Box sont utilisées par des automates : elles ne peuvent pas en avoir,
|
||||||
|
c'est justement pourquoi leurs privilèges sont limités.
|
||||||
|
|
||||||
|
**Sauvegardez les trois clés privées dans votre gestionnaire de mots de passe.**
|
||||||
|
Perdre la clé d'administration signifie repasser par la console Hetzner en mode
|
||||||
|
secours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Clé de chiffrement des secrets
|
||||||
|
|
||||||
|
C'est **la** clé à ne pas perdre : elle déchiffre tous les secrets de
|
||||||
|
production. Sa création est détaillée dans [06-secrets-sops.md](./06-secrets-sops.md),
|
||||||
|
mais générez-la maintenant, vous en aurez besoin partout :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/sops/age
|
||||||
|
age-keygen -o ~/.config/sops/age/keys.txt
|
||||||
|
chmod 600 ~/.config/sops/age/keys.txt
|
||||||
|
grep 'public key' ~/.config/sops/age/keys.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Générez **aussi une clé de secours**, stockée hors ligne (papier dans un coffre,
|
||||||
|
ou clé USB chiffrée rangée ailleurs). Les deux clés publiques iront dans
|
||||||
|
`infra/prod/.sops.yaml`. Sans clé de secours, la perte de votre poste = la perte
|
||||||
|
définitive de tous les secrets de production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Contrôle avant de passer à la suite
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Projet Hetzner Cloud `xpeditis-prod` créé, 2FA activée
|
||||||
|
[ ] Storage Box BX11 commandée (l'activation prend jusqu'à 1 h)
|
||||||
|
[ ] Zone Cloudflare active pour xpeditis.com, 2FA activée
|
||||||
|
[ ] Compte Stripe en mode Live, webhook de production créé
|
||||||
|
[ ] Nouvelle clé SMTP Brevo de production créée
|
||||||
|
[ ] Domaine expéditeur vérifié chez Brevo (SPF/DKIM prêts à publier)
|
||||||
|
[ ] ops@xpeditis.com relevée
|
||||||
|
[ ] IP d'administration connue et stable
|
||||||
|
[ ] Outils installés (terraform, kubectl, sops, age, hcloud, jq)
|
||||||
|
[ ] 3 clés SSH générées et sauvegardées
|
||||||
|
[ ] Clé age principale + clé de secours générées
|
||||||
|
[ ] Webhooks Discord créés
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [02 — Provisioning Hetzner](./02-provisioning-hetzner.md)**
|
||||||
211
docs/mise-en-prod/02-provisioning-hetzner.md
Normal file
211
docs/mise-en-prod/02-provisioning-hetzner.md
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
# 02 — Provisioning Hetzner
|
||||||
|
|
||||||
|
**Durée : environ 1 h.** Création des serveurs, du réseau privé, des firewalls
|
||||||
|
et du volume de données, entièrement décrite en Terraform.
|
||||||
|
|
||||||
|
> Le fichier de prévisions de coûts note explicitement le risque
|
||||||
|
> « bus factor DevOps — Terraform/IaC obligatoire ». C'est la raison d'être de
|
||||||
|
> ce dossier : l'infrastructure est reconstructible par quelqu'un d'autre, à
|
||||||
|
> partir du dépôt seul.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Token API Hetzner
|
||||||
|
|
||||||
|
Console Hetzner → projet `xpeditis-prod` → **Security → API tokens** →
|
||||||
|
*Generate API token*.
|
||||||
|
|
||||||
|
- Description : `terraform-prod`
|
||||||
|
- Permissions : **Read & Write**
|
||||||
|
|
||||||
|
Le token n'est affiché **qu'une fois**. Copiez-le dans votre gestionnaire de
|
||||||
|
mots de passe immédiatement.
|
||||||
|
|
||||||
|
Créez un **second token** nommé `github-actions-cicd`, également Read & Write.
|
||||||
|
Il servira uniquement au firewall temporaire de la CI, ce qui permet de le
|
||||||
|
révoquer sans casser Terraform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod/terraform
|
||||||
|
cp terraform.tfvars.example terraform.tfvars
|
||||||
|
$EDITOR terraform.tfvars
|
||||||
|
```
|
||||||
|
|
||||||
|
À renseigner :
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
hcloud_token = "<token terraform-prod>"
|
||||||
|
ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin" # cat ~/.ssh/xpeditis_prod.pub
|
||||||
|
admin_ip_allowlist = ["<votre IP>/32"] # curl -s https://ifconfig.me
|
||||||
|
```
|
||||||
|
|
||||||
|
Le reste des valeurs par défaut correspond au dimensionnement de la phase 1 :
|
||||||
|
`cpx41` pour l'application, `cpx31` pour les données, volume de 50 Go,
|
||||||
|
sauvegardes Hetzner activées.
|
||||||
|
|
||||||
|
> `terraform.tfvars` est ignoré par Git (`infra/prod/.gitignore`). Vérifiez-le
|
||||||
|
> avant tout commit : `git status` ne doit pas le mentionner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vérifier avant d'appliquer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C .. tf-init # ou : terraform init
|
||||||
|
make -C .. tf-plan # ou : terraform plan
|
||||||
|
```
|
||||||
|
|
||||||
|
Le plan doit annoncer **8 ressources à créer** :
|
||||||
|
|
||||||
|
```
|
||||||
|
hcloud_network.main
|
||||||
|
hcloud_network_subnet.main
|
||||||
|
hcloud_ssh_key.admin
|
||||||
|
hcloud_placement_group.spread
|
||||||
|
hcloud_firewall.app
|
||||||
|
hcloud_firewall.db
|
||||||
|
hcloud_firewall.cicd
|
||||||
|
hcloud_server.app
|
||||||
|
hcloud_server.db
|
||||||
|
hcloud_volume.pgdata
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois points à relire dans le plan :
|
||||||
|
|
||||||
|
1. `hcloud_firewall.app` — les règles 22 et 6443 portent **uniquement** votre
|
||||||
|
IP. Si vous y voyez `0.0.0.0/0`, arrêtez tout.
|
||||||
|
2. `hcloud_firewall.db` — **seul le port 22** est ouvert. Ni 5432, ni 6379.
|
||||||
|
3. `hcloud_firewall.cicd` — **aucune règle**. C'est normal : la CI les injecte
|
||||||
|
puis les retire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Appliquer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C .. tf-apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Environ 90 secondes. Puis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
terraform output
|
||||||
|
```
|
||||||
|
|
||||||
|
Notez précieusement :
|
||||||
|
|
||||||
|
```
|
||||||
|
app_public_ipv4 = "..." → tous les enregistrements DNS Cloudflare
|
||||||
|
db_public_ipv4 = "..." → SSH d'administration UNIQUEMENT, jamais en DNS
|
||||||
|
db_private_ip = "10.10.1.20"
|
||||||
|
pgdata_volume_device = "/dev/disk/by-id/scsi-0HC_Volume_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vérifier l'accès
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> 'hostname; uptime'
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> 'hostname; uptime'
|
||||||
|
```
|
||||||
|
|
||||||
|
cloud-init met une à deux minutes après la création du serveur. Si la connexion
|
||||||
|
est refusée, attendez puis vérifiez :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<ip> 'ls -l /var/log/cloud-init-xpeditis-done'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le réseau privé doit fonctionner dans les deux sens :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<app_public_ipv4> 'ping -c2 10.10.1.20'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Confirmer que la base est bien inaccessible
|
||||||
|
|
||||||
|
**Depuis un autre réseau que votre IP d'administration** (partage de connexion
|
||||||
|
mobile, par exemple) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nmap -Pn -p 22,80,443,5432,6379,6443 <db_public_ipv4>
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : **tous les ports `filtered`**, y compris le 22 — puisque vous testez
|
||||||
|
depuis une IP non autorisée.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nmap -Pn -p 22,80,443,5432,6379,6443 <app_public_ipv4>
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu à ce stade : `80` et `443` ouverts uniquement si
|
||||||
|
`restrict_http_to_cloudflare = false`. Avec la valeur par défaut `true`, ils
|
||||||
|
apparaissent aussi `filtered` tant que la requête ne vient pas de Cloudflare —
|
||||||
|
c'est exactement l'effet recherché.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Storage Box
|
||||||
|
|
||||||
|
La Storage Box se commande séparément (Robot Hetzner, pas la console Cloud).
|
||||||
|
|
||||||
|
1. Commandez une **BX11** (1 To, 3,90 €/mois).
|
||||||
|
2. Dans son interface : activez **SSH** et **désactivez** Samba/CIFS et WebDAV,
|
||||||
|
inutiles ici et exposés sur Internet.
|
||||||
|
3. Déposez la clé publique dédiée :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis votre poste
|
||||||
|
ssh-copy-id -p 23 -i ~/.ssh/xpeditis_storagebox.pub uXXXXXX@uXXXXXX.your-storagebox.de
|
||||||
|
|
||||||
|
# Créez l'arborescence
|
||||||
|
ssh -p 23 -i ~/.ssh/xpeditis_storagebox uXXXXXX@uXXXXXX.your-storagebox.de \
|
||||||
|
'mkdir -p xpeditis-prod/dumps'
|
||||||
|
```
|
||||||
|
|
||||||
|
La clé **privée** correspondante devra être déposée sur db-01 en
|
||||||
|
`/root/.ssh/storagebox` ([04 — Nœud de données](./04-noeud-donnees.md)).
|
||||||
|
|
||||||
|
> **Activez les *snapshots* de la Storage Box** (gratuits, dans son interface).
|
||||||
|
> Ils protègent contre le cas où un rançongiciel présent sur db-01 chiffrerait
|
||||||
|
> ou effacerait aussi les sauvegardes distantes via la connexion SSH existante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sauvegarder l'état Terraform
|
||||||
|
|
||||||
|
`terraform.tfstate` décrit toute votre infrastructure. Il est gitignoré à
|
||||||
|
dessein (il contient des données sensibles), mais **le perdre signifie que
|
||||||
|
Terraform ne reconnaîtra plus vos ressources** et proposera de tout recréer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sauvegarde chiffrée dans le dépôt
|
||||||
|
sops -e terraform.tfstate > ../terraform-state-backup.sops.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou, mieux, migrez vers un backend S3 sur Hetzner Object Storage : le bloc
|
||||||
|
`backend "s3"` est prêt, en commentaire, dans `versions.tf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] terraform apply passé sans erreur
|
||||||
|
[ ] SSH fonctionne sur app-01 et db-01 avec la clé d'administration
|
||||||
|
[ ] Le ping app-01 → 10.10.1.20 répond
|
||||||
|
[ ] nmap depuis une IP non autorisée : tout est filtered
|
||||||
|
[ ] Storage Box commandée, SSH activé, Samba/WebDAV désactivés
|
||||||
|
[ ] Snapshots de la Storage Box activés
|
||||||
|
[ ] État Terraform sauvegardé
|
||||||
|
[ ] Les deux IP publiques notées dans le gestionnaire de mots de passe
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [03 — Durcissement des serveurs](./03-durcissement-serveurs.md)**
|
||||||
180
docs/mise-en-prod/03-durcissement-serveurs.md
Normal file
180
docs/mise-en-prod/03-durcissement-serveurs.md
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
# 03 — Durcissement des serveurs
|
||||||
|
|
||||||
|
**Durée : environ 1 h pour les deux serveurs.**
|
||||||
|
|
||||||
|
cloud-init a posé le minimum vital (compte `deploy`, SSH par clé, UFW fermé).
|
||||||
|
Ce script va nettement plus loin et rend le résultat vérifiable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ce que fait `00-bootstrap-common.sh`
|
||||||
|
|
||||||
|
| Domaine | Mesure | Pourquoi |
|
||||||
|
|---|---|---|
|
||||||
|
| Mises à jour | `unattended-upgrades`, redémarrage automatique à 04h30 si le noyau l'exige | Une faille non corrigée pendant six mois est la première cause de compromission d'un serveur laissé seul. |
|
||||||
|
| SSH | Clé uniquement, `root` interdit, 3 tentatives, algorithmes modernes, `AllowUsers deploy` | Supprime l'attaque par mot de passe et réduit la surface cryptographique. |
|
||||||
|
| fail2ban | Bannissement 24 h après 3 échecs, réseau privé exclu | Ralentit le balayage automatisé permanent d'Internet. |
|
||||||
|
| sysctl | Anti-spoofing, pas de redirections ICMP, `kptr_restrict`, `ptrace_scope` | Rend l'escalade locale nettement plus difficile après une compromission applicative. |
|
||||||
|
| journald | Plafonné à 2 Go, 30 jours | Des journaux qui remplissent le disque provoquent une panne totale. |
|
||||||
|
| auditd | Trace `passwd`, `shadow`, `sudoers`, `sshd_config`, commandes root | Sans journal d'audit, une intrusion est indémontrable. |
|
||||||
|
| chrony | Fuseau Europe/Paris, NTP | Une horloge décalée invalide les JWT, casse TLS et rend les `audit_logs` inexploitables. |
|
||||||
|
| UFW | Refus par défaut, ouvertures selon le rôle | Le firewall Hetzner ne filtre **que** les interfaces publiques : le trafic du réseau privé n'est filtré que par UFW. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Exécution
|
||||||
|
|
||||||
|
### app-01
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/00-bootstrap-common.sh \
|
||||||
|
deploy@<app_public_ipv4>:/tmp/
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
|
||||||
|
'sudo bash /tmp/00-bootstrap-common.sh app'
|
||||||
|
```
|
||||||
|
|
||||||
|
### db-01
|
||||||
|
|
||||||
|
`APP_PRIVATE_IP` détermine qui a le droit d'atteindre PostgreSQL et Redis.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/00-bootstrap-common.sh \
|
||||||
|
deploy@<db_public_ipv4>:/tmp/
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
|
||||||
|
'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Gardez la session SSH ouverte** pendant que le script tourne. Il redémarre
|
||||||
|
> `sshd` : si la configuration était invalide, `sshd -t` échouerait avant le
|
||||||
|
> redémarrage, mais mieux vaut pouvoir corriger sans passer par la console de
|
||||||
|
> secours Hetzner. Ouvrez un **second terminal** et vérifiez que vous arrivez
|
||||||
|
> encore à vous connecter **avant** de fermer le premier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vérifications
|
||||||
|
|
||||||
|
Sur chaque serveur :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<ip> '
|
||||||
|
echo "--- SSH ---"
|
||||||
|
sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|maxauthtries|allowusers)"
|
||||||
|
echo "--- Services ---"
|
||||||
|
systemctl is-active fail2ban unattended-upgrades auditd chrony
|
||||||
|
echo "--- Pare-feu ---"
|
||||||
|
sudo ufw status verbose
|
||||||
|
echo "--- Horloge ---"
|
||||||
|
timedatectl | grep -E "Time zone|synchronized"
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu :
|
||||||
|
|
||||||
|
```
|
||||||
|
permitrootlogin no
|
||||||
|
passwordauthentication no
|
||||||
|
maxauthtries 3
|
||||||
|
allowusers deploy
|
||||||
|
active × 4
|
||||||
|
Status: active (avec les règles correspondant au rôle)
|
||||||
|
System clock synchronized: yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Règles UFW attendues
|
||||||
|
|
||||||
|
**app-01**
|
||||||
|
```
|
||||||
|
22/tcp ALLOW Anywhere # SSH (filtré en amont par Hetzner)
|
||||||
|
80/tcp ALLOW Anywhere # HTTP (filtré en amont par Cloudflare)
|
||||||
|
443/tcp ALLOW Anywhere # HTTPS
|
||||||
|
6443/tcp ALLOW Anywhere # API k3s (filtré en amont)
|
||||||
|
```
|
||||||
|
|
||||||
|
**db-01**
|
||||||
|
```
|
||||||
|
22/tcp ALLOW Anywhere
|
||||||
|
5432/tcp ALLOW 10.10.1.10 # PostgreSQL ← app-01 uniquement
|
||||||
|
6379/tcp ALLOW 10.10.1.10 # Redis ← app-01 uniquement
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `5432 ALLOW Anywhere` apparaît sur db-01, **arrêtez et corrigez** : la base
|
||||||
|
serait joignable depuis n'importe quelle machine du réseau privé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Durcissement complémentaire (recommandé)
|
||||||
|
|
||||||
|
### 4.1 Protéger la console Hetzner
|
||||||
|
|
||||||
|
La console web Hetzner donne un accès clavier au serveur, **sans passer par
|
||||||
|
SSH**. Elle contourne donc tout ce qui précède.
|
||||||
|
|
||||||
|
- Activez la 2FA sur le compte Hetzner (si ce n'est pas déjà fait).
|
||||||
|
- Ne définissez **jamais** de mot de passe root sur les serveurs : sans mot de
|
||||||
|
passe, la console ne permet pas de se connecter.
|
||||||
|
|
||||||
|
Vérifiez qu'aucun mot de passe n'est défini :
|
||||||
|
```bash
|
||||||
|
ssh deploy@<ip> 'sudo passwd -S root' # doit afficher "L" (locked)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Alertes de connexion SSH
|
||||||
|
|
||||||
|
Pour être prévenu de toute connexion réussie :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<ip> 'sudo tee /etc/profile.d/99-ssh-alert.sh >/dev/null' <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
# Notifie Discord a chaque ouverture de session SSH interactive.
|
||||||
|
[ -n "$SSH_CONNECTION" ] || return 0
|
||||||
|
[ -f /etc/xpeditis/discord-webhook ] || return 0
|
||||||
|
WEBHOOK=$(cat /etc/xpeditis/discord-webhook)
|
||||||
|
curl -sf -m 5 -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"content\":\"SSH sur \`$(hostname)\` : ${USER} depuis ${SSH_CONNECTION%% *}\"}" \
|
||||||
|
"$WEBHOOK" >/dev/null 2>&1 &
|
||||||
|
EOF
|
||||||
|
|
||||||
|
ssh deploy@<ip> '
|
||||||
|
sudo mkdir -p /etc/xpeditis
|
||||||
|
echo "<webhook_discord_alertes>" | sudo tee /etc/xpeditis/discord-webhook >/dev/null
|
||||||
|
sudo chmod 600 /etc/xpeditis/discord-webhook
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Une notification pour chacune de vos propres connexions est le prix à payer
|
||||||
|
pour repérer immédiatement celle qui n'est pas la vôtre.
|
||||||
|
|
||||||
|
### 4.3 Bannissement permanent des récidivistes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<ip> 'sudo tee /etc/fail2ban/jail.d/recidive.local >/dev/null' <<'EOF'
|
||||||
|
[recidive]
|
||||||
|
enabled = true
|
||||||
|
logpath = /var/log/fail2ban.log
|
||||||
|
banaction = iptables-allports
|
||||||
|
bantime = 1w
|
||||||
|
findtime = 1d
|
||||||
|
maxretry = 3
|
||||||
|
EOF
|
||||||
|
ssh deploy@<ip> 'sudo systemctl restart fail2ban'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Script exécuté sur app-01 (rôle app) et db-01 (rôle data)
|
||||||
|
[ ] SSH : root interdit, mot de passe interdit, AllowUsers deploy
|
||||||
|
[ ] fail2ban, unattended-upgrades, auditd, chrony actifs sur les deux
|
||||||
|
[ ] UFW db-01 : 5432 et 6379 restreints à 10.10.1.10
|
||||||
|
[ ] Horloge synchronisée sur les deux
|
||||||
|
[ ] Compte root verrouillé (passwd -S root → L)
|
||||||
|
[ ] Une seconde session SSH a été testée avant de fermer la première
|
||||||
|
[ ] Alertes SSH Discord en place (optionnel mais recommandé)
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [04 — Nœud de données](./04-noeud-donnees.md)**
|
||||||
337
docs/mise-en-prod/04-noeud-donnees.md
Normal file
337
docs/mise-en-prod/04-noeud-donnees.md
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
# 04 — Nœud de données (PostgreSQL + Redis)
|
||||||
|
|
||||||
|
**Durée : environ 2 h.** C'est l'étape la plus délicate : c'est la seule dont
|
||||||
|
les erreurs ne se rattrapent pas en redéployant.
|
||||||
|
|
||||||
|
> **Prérequis** — WAL-G archive les journaux de transaction dès le premier
|
||||||
|
> démarrage de PostgreSQL. Créez d'abord le bucket de sauvegarde et ses clés :
|
||||||
|
> [07 — Stockage objet, sections 1 et 2](./07-stockage-objet-s3.md). Revenez
|
||||||
|
> ensuite ici. Sans le bucket, PostgreSQL démarre quand même mais accumule ses
|
||||||
|
> WAL sur disque jusqu'à saturation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Choix d'architecture, et pourquoi
|
||||||
|
|
||||||
|
**PostgreSQL est hors de Kubernetes.** Ce n'est pas un raccourci, c'est un
|
||||||
|
choix :
|
||||||
|
|
||||||
|
- Un `StatefulSet` PostgreSQL impose des volumes persistants, un ordre de
|
||||||
|
démarrage, et transforme chaque montée de version majeure en opération à
|
||||||
|
risque.
|
||||||
|
- Hors cluster, `pg_dump`, WAL-G, la restauration à un instant T et les tests
|
||||||
|
de restauration sont des commandes ordinaires.
|
||||||
|
- Le nœud applicatif redevient entièrement jetable : on peut le détruire et le
|
||||||
|
reconstruire sans jamais approcher les données.
|
||||||
|
|
||||||
|
**Le trafic vers la base est chiffré.** Le réseau privé Hetzner isole les
|
||||||
|
projets mais ne chiffre pas les paquets. `pg_hba.conf` n'accepte que des lignes
|
||||||
|
`hostssl` : une connexion en clair est refusée, y compris depuis app-01.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/01-setup-data-node.sh \
|
||||||
|
deploy@<db_public_ipv4>:/tmp/
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
|
||||||
|
'sudo bash /tmp/01-setup-data-node.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script :
|
||||||
|
1. formate (si vierge) et monte le volume Hetzner sur `/var/lib/xpeditis/pgdata`,
|
||||||
|
avec `nofail` pour que le serveur démarre même si le volume manque ;
|
||||||
|
2. installe Docker Engine depuis le dépôt officiel et le durcit
|
||||||
|
(`no-new-privileges`, `icc: false`, rotation des journaux) ;
|
||||||
|
3. génère le certificat TLS interne de PostgreSQL ;
|
||||||
|
4. installe `age` ;
|
||||||
|
5. installe les timers systemd de sauvegarde.
|
||||||
|
|
||||||
|
Il **ne démarre pas** la base : les secrets ne sont pas encore là.
|
||||||
|
|
||||||
|
### Vérifier le volume
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_public_ipv4> 'df -h /var/lib/xpeditis/pgdata; lsblk'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le point de montage doit apparaître avec la taille du volume (50 Go), pas celle
|
||||||
|
du disque système. Si le volume n'a pas été détecté, `/var/lib/xpeditis/pgdata`
|
||||||
|
est resté sur le disque système : corrigez **avant** d'écrire la moindre donnée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Copier la configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis le dépôt, sur votre poste
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \
|
||||||
|
infra/prod/data-node/ \
|
||||||
|
deploy@<db_public_ipv4>:/tmp/data-node/
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
|
||||||
|
sudo mkdir -p /opt/xpeditis/data-node
|
||||||
|
sudo rsync -a /tmp/data-node/ /opt/xpeditis/data-node/
|
||||||
|
sudo chown -R root:root /opt/xpeditis/data-node
|
||||||
|
sudo chmod +x /opt/xpeditis/data-node/backup/*.sh
|
||||||
|
rm -rf /tmp/data-node
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Relancez ensuite le script d'installation. Il est idempotent : cette seconde
|
||||||
|
passe ne refait rien de ce qui est déjà en place, mais elle trouve désormais les
|
||||||
|
fichiers `backup/` et installe les timers systemd.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> \
|
||||||
|
'sudo bash /tmp/01-setup-data-node.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Secrets
|
||||||
|
|
||||||
|
### 4.1 Générer les mots de passe
|
||||||
|
|
||||||
|
Sur **votre poste**, jamais sur le serveur (l'historique du shell garde tout) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)"
|
||||||
|
echo "REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)"
|
||||||
|
# WAL-G attend 32 octets en HEXADÉCIMAL (64 caractères), pas en base64.
|
||||||
|
echo "WALG_LIBSODIUM_KEY=$(openssl rand -hex 32)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Générez aussi la paire age dédiée au chiffrement des dumps :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
age-keygen -o /tmp/backup-age.key
|
||||||
|
grep 'public key' /tmp/backup-age.key # → BACKUP_AGE_RECIPIENT
|
||||||
|
```
|
||||||
|
|
||||||
|
> `WALG_LIBSODIUM_KEY` et la clé privée `backup-age.key` sont **les deux clés
|
||||||
|
> sans lesquelles aucune restauration n'est possible**. Sauvegardez-les dans
|
||||||
|
> votre gestionnaire de mots de passe **et** hors ligne, avant d'aller plus
|
||||||
|
> loin. Une sauvegarde chiffrée dont on a perdu la clé est un fichier inutile.
|
||||||
|
|
||||||
|
### 4.2 Composer le fichier d'environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp infra/prod/env/data-node.env.example /tmp/.env.data
|
||||||
|
$EDITOR /tmp/.env.data
|
||||||
|
```
|
||||||
|
|
||||||
|
Renseignez : `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, les identifiants WAL-G
|
||||||
|
(bucket créé en [07](./07-stockage-objet-s3.md)), `WALG_LIBSODIUM_KEY`,
|
||||||
|
`BACKUP_AGE_RECIPIENT`, les coordonnées de la Storage Box, les webhooks.
|
||||||
|
|
||||||
|
### 4.3 Chiffrer pour Git, déposer en clair sur le serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Version chiffrée, versionnée
|
||||||
|
cd infra/prod
|
||||||
|
sops -e --input-type dotenv --output-type dotenv /tmp/.env.data \
|
||||||
|
> data-node/data-node.sops.env
|
||||||
|
|
||||||
|
# Version en clair, uniquement sur db-01
|
||||||
|
scp -i ~/.ssh/xpeditis_prod /tmp/.env.data deploy@<db_public_ipv4>:/tmp/
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
|
||||||
|
sudo install -m 600 -o root -g root /tmp/.env.data /opt/xpeditis/data-node/.env.data
|
||||||
|
shred -u /tmp/.env.data
|
||||||
|
'
|
||||||
|
|
||||||
|
# Effacer la copie locale
|
||||||
|
shred -u /tmp/.env.data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Clés privées sur db-01
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clé age de déchiffrement des dumps
|
||||||
|
scp -i ~/.ssh/xpeditis_prod /tmp/backup-age.key deploy@<db_public_ipv4>:/tmp/
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
|
||||||
|
sudo mkdir -p /root/.config/xpeditis && sudo chmod 700 /root/.config/xpeditis
|
||||||
|
sudo install -m 600 -o root -g root /tmp/backup-age.key /root/.config/xpeditis/backup-age.key
|
||||||
|
shred -u /tmp/backup-age.key
|
||||||
|
'
|
||||||
|
shred -u /tmp/backup-age.key
|
||||||
|
|
||||||
|
# Clé SSH vers la Storage Box
|
||||||
|
scp -i ~/.ssh/xpeditis_prod ~/.ssh/xpeditis_storagebox deploy@<db_public_ipv4>:/tmp/sb.key
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4> '
|
||||||
|
sudo install -m 600 -o root -g root /tmp/sb.key /root/.ssh/storagebox
|
||||||
|
shred -u /tmp/sb.key
|
||||||
|
# Enregistre l empreinte de la Storage Box : StrictHostKeyChecking=yes
|
||||||
|
# échouerait sinon, et le rsync de sauvegarde avec lui.
|
||||||
|
sudo ssh-keyscan -p 23 uXXXXXX.your-storagebox.de | sudo tee -a /root/.ssh/known_hosts
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Démarrage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<db_public_ipv4>
|
||||||
|
cd /opt/xpeditis/data-node
|
||||||
|
sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
La construction de l'image PostgreSQL + WAL-G prend deux à trois minutes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose -f docker-compose.data.yml --env-file .env.data ps
|
||||||
|
sudo docker compose -f docker-compose.data.yml --env-file .env.data logs postgres | tail -40
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu dans les journaux :
|
||||||
|
```
|
||||||
|
database system is ready to accept connections
|
||||||
|
```
|
||||||
|
|
||||||
|
Si PostgreSQL refuse de démarrer, la cause est presque toujours l'une de ces
|
||||||
|
trois :
|
||||||
|
|
||||||
|
| Message | Cause | Correction |
|
||||||
|
|---|---|---|
|
||||||
|
| `could not load server certificate file` | droits du certificat | `chown 999:999` et `chmod 600` sur `/var/lib/xpeditis/certs/server.key` |
|
||||||
|
| `unrecognized configuration parameter` | faute de frappe dans `postgresql.conf` | corriger, puis `docker compose restart postgres` |
|
||||||
|
| `data directory has wrong ownership` | volume monté après le premier démarrage | `chown -R 999:999 /var/lib/xpeditis/pgdata` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Vérifications
|
||||||
|
|
||||||
|
### 6.1 TLS obligatoire
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis db-01 : connexion locale (socket) — doit fonctionner
|
||||||
|
sudo docker compose exec -u postgres postgres psql -c 'SELECT version();'
|
||||||
|
|
||||||
|
# Le chiffrement est-il actif ?
|
||||||
|
sudo docker compose exec -u postgres postgres \
|
||||||
|
psql -c "SELECT name, setting FROM pg_settings WHERE name IN ('ssl','password_encryption');"
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : `ssl = on`, `password_encryption = scram-sha-256`.
|
||||||
|
|
||||||
|
### 6.2 Depuis app-01, la connexion doit passer en TLS et **uniquement** en TLS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<app_public_ipv4>
|
||||||
|
sudo apt-get install -y postgresql-client
|
||||||
|
|
||||||
|
# Doit RÉUSSIR
|
||||||
|
PGPASSWORD='<POSTGRES_PASSWORD>' psql "host=10.10.1.20 port=5432 dbname=xpeditis_prod user=xpeditis sslmode=require" -c '\conninfo'
|
||||||
|
|
||||||
|
# Doit ÉCHOUER — c'est le résultat attendu
|
||||||
|
PGPASSWORD='<POSTGRES_PASSWORD>' psql "host=10.10.1.20 port=5432 dbname=xpeditis_prod user=xpeditis sslmode=disable" -c 'SELECT 1;'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le second doit renvoyer :
|
||||||
|
```
|
||||||
|
FATAL: no pg_hba.conf entry for host "10.10.1.10", ... SSL off
|
||||||
|
```
|
||||||
|
|
||||||
|
Si la connexion **sans** TLS réussit, `pg_hba.conf` n'a pas été pris en compte :
|
||||||
|
vérifiez que le conteneur est bien lancé avec `-c hba_file=/etc/postgresql/pg_hba.conf`.
|
||||||
|
|
||||||
|
### 6.3 Redis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis app-01
|
||||||
|
redis-cli -h 10.10.1.20 -a '<REDIS_PASSWORD>' --no-auth-warning ping # PONG
|
||||||
|
redis-cli -h 10.10.1.20 --no-auth-warning ping # NOAUTH
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 Rien n'est exposé publiquement
|
||||||
|
|
||||||
|
**Depuis un autre réseau :**
|
||||||
|
```bash
|
||||||
|
nmap -Pn -p 5432,6379,9187 <db_public_ipv4>
|
||||||
|
```
|
||||||
|
Les trois doivent être `filtered`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Première sauvegarde
|
||||||
|
|
||||||
|
Elle doit être prise **avant** que la moindre donnée réelle n'existe : cela
|
||||||
|
valide la chaîne complète pendant que l'enjeu est nul.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_public_ipv4> 'sudo systemctl start xpeditis-backup.service'
|
||||||
|
ssh deploy@<db_public_ipv4> 'sudo journalctl -u xpeditis-backup -n 60 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu :
|
||||||
|
```
|
||||||
|
WAL-G basebackup : termine
|
||||||
|
pg_dump : NNNNN octets
|
||||||
|
Envoi vers la Storage Box ...
|
||||||
|
Sauvegarde terminee.
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifiez les deux destinations :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WAL-G sur Object Storage
|
||||||
|
ssh deploy@<db_public_ipv4> \
|
||||||
|
'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres wal-g backup-list --detail'
|
||||||
|
|
||||||
|
# Dumps sur la Storage Box
|
||||||
|
ssh -p 23 -i ~/.ssh/xpeditis_storagebox uXXXXXX@uXXXXXX.your-storagebox.de \
|
||||||
|
'ls -lh xpeditis-prod/dumps/'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timers actifs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_public_ipv4> "systemctl list-timers 'xpeditis-*' --no-pager"
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : `xpeditis-backup.timer` (quotidien 02h30) et
|
||||||
|
`xpeditis-backup-verify.timer` (dimanche 04h00).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Réglages à connaître
|
||||||
|
|
||||||
|
| Paramètre | Valeur | Conséquence |
|
||||||
|
|---|---|---|
|
||||||
|
| `shared_buffers` | 2 Go | Sur 8 Go de RAM, dont 1,5 Go plafonnés pour Redis. |
|
||||||
|
| `max_connections` | 150 | 2 replicas backend + marge d'exploitation. Une alerte se déclenche à 80 %. |
|
||||||
|
| `statement_timeout` | 5 min | Borne les requêtes folles tout en laissant passer les imports de grilles CSV. |
|
||||||
|
| `archive_timeout` | 5 min | **Plafonne la perte de données à 5 minutes** en cas de perte totale du serveur. |
|
||||||
|
| `log_min_duration_statement` | 500 ms | Toute requête lente est tracée dans Loki. |
|
||||||
|
| `log_statement` | `ddl` | Chaque changement de schéma (migration) laisse une trace. |
|
||||||
|
| Redis `maxmemory-policy` | `volatile-lru` | Seules les clés à TTL sont évincées. Voir l'avertissement ci-dessous. |
|
||||||
|
|
||||||
|
> **Redis et l'éviction.** `volatile-lru` n'évince que les clés porteuses d'un
|
||||||
|
> TTL — les cotations tarifaires, qui sont sacrifiables. Mais les entrées de
|
||||||
|
> révocation de jetons ont elles aussi un TTL : sous saturation mémoire, l'une
|
||||||
|
> d'elles pourrait être évincée, redonnant validité à un jeton révoqué.
|
||||||
|
> `maxmemory` est fixé à 1 Go pour 0,5 Go estimé, et une alerte se déclenche à
|
||||||
|
> 75 % d'occupation. Si elle se déclenche, augmentez `maxmemory` — ne changez
|
||||||
|
> pas la politique d'éviction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Volume Hetzner monté sur /var/lib/xpeditis/pgdata (bonne taille)
|
||||||
|
[ ] PostgreSQL démarré, ssl = on, password_encryption = scram-sha-256
|
||||||
|
[ ] Connexion TLS depuis app-01 : OK
|
||||||
|
[ ] Connexion NON TLS depuis app-01 : REFUSÉE
|
||||||
|
[ ] Redis répond avec mot de passe, refuse sans
|
||||||
|
[ ] 5432 / 6379 / 9187 filtered depuis Internet
|
||||||
|
[ ] Première sauvegarde réussie, visible sur Object Storage ET Storage Box
|
||||||
|
[ ] Timers xpeditis-backup et xpeditis-backup-verify actifs
|
||||||
|
[ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées hors ligne
|
||||||
|
[ ] .env.data en clair effacé du poste et de /tmp du serveur
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [05 — Cluster k3s](./05-cluster-k3s.md)**
|
||||||
181
docs/mise-en-prod/05-cluster-k3s.md
Normal file
181
docs/mise-en-prod/05-cluster-k3s.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# 05 — Cluster k3s
|
||||||
|
|
||||||
|
**Durée : environ 1 h 30.**
|
||||||
|
|
||||||
|
k3s est une distribution Kubernetes complète en un seul binaire. Sur un nœud, il
|
||||||
|
consomme environ 500 Mo de RAM — comparable à Docker Swarm, pour un modèle de
|
||||||
|
déploiement bien plus riche (sondes, rolling updates sans coupure, politiques
|
||||||
|
réseau, quotas).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -i ~/.ssh/xpeditis_prod infra/prod/scripts/02-setup-k3s-server.sh \
|
||||||
|
deploy@<app_public_ipv4>:/tmp/
|
||||||
|
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
|
||||||
|
"sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP=<app_public_ipv4> PRIVATE_IP=10.10.1.10 \
|
||||||
|
bash /tmp/02-setup-k3s-server.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comptez trois à cinq minutes.
|
||||||
|
|
||||||
|
### Ce que le script durcit, et pourquoi
|
||||||
|
|
||||||
|
| Option | Effet |
|
||||||
|
|---|---|
|
||||||
|
| `--secrets-encryption` | Les `Secret` sont chiffrés dans la base d'état de k3s. Sans cela, un instantané de disque ou un vol de volume livre **tous** les secrets en clair. |
|
||||||
|
| `--protect-kernel-defaults` | kubelet refuse de démarrer si les `sysctl` attendus ne sont pas posés. Échec bruyant plutôt que dérive silencieuse. |
|
||||||
|
| `audit-log-*` | Journal d'audit de l'API, 30 jours. Sans lui, « qui a supprimé ce déploiement ? » reste sans réponse. La politique fournie ne journalise **jamais** le contenu des `Secret`. |
|
||||||
|
| `--write-kubeconfig-mode=0600` | Le kubeconfig n'est pas lisible par tous les utilisateurs du serveur. |
|
||||||
|
| `--advertise-address=10.10.1.10` | Le cluster s'annonce sur le réseau privé. |
|
||||||
|
| `--etcd-expose-metrics=false` | Réduit la surface exposée. |
|
||||||
|
|
||||||
|
### Traefik
|
||||||
|
|
||||||
|
Le script écrit `/var/lib/rancher/k3s/server/manifests/traefik-config.yaml`
|
||||||
|
**avant** l'installation, pour que k3s applique la configuration dès le premier
|
||||||
|
démarrage :
|
||||||
|
|
||||||
|
- redirection HTTP → HTTPS au niveau de l'*entrypoint* — aucun Ingress ne peut
|
||||||
|
l'oublier ;
|
||||||
|
- **IP Cloudflare déclarées de confiance** pour `X-Forwarded-For` : sans cela,
|
||||||
|
la limitation de débit et les `audit_logs` verraient tous l'IP de Cloudflare,
|
||||||
|
et un seul abus bloquerait tout le monde ;
|
||||||
|
- journaux d'accès en JSON, en-têtes filtrés (seuls `User-Agent` et
|
||||||
|
`Cf-Connecting-Ip` sont conservés) — les autres peuvent contenir des jetons ;
|
||||||
|
- **tableau de bord Traefik désactivé** ;
|
||||||
|
- métriques Prometheus activées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Récupérer le kubeconfig
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> 'cat ~/.kube/config' \
|
||||||
|
| sed "s/127.0.0.1/<app_public_ipv4>/" > ~/.kube/xpeditis-prod.yaml
|
||||||
|
chmod 600 ~/.kube/xpeditis-prod.yaml
|
||||||
|
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
kubectl get nodes -o wide
|
||||||
|
```
|
||||||
|
|
||||||
|
> Ce fichier donne **les pleins pouvoirs** sur le cluster. Traitez-le comme une
|
||||||
|
> clé privée : `chmod 600`, jamais dans Git, jamais dans un secret GitHub.
|
||||||
|
> Il n'est utilisable que depuis vos IP d'administration (firewall Hetzner).
|
||||||
|
|
||||||
|
Pour l'utiliser en permanence :
|
||||||
|
```bash
|
||||||
|
echo 'export KUBECONFIG=~/.kube/xpeditis-prod.yaml' >> ~/.zshrc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vérifications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl get nodes
|
||||||
|
# NAME STATUS ROLES VERSION
|
||||||
|
# xpeditis-prod-app-01 Ready control-plane,master v1.31.5+k3s1
|
||||||
|
|
||||||
|
kubectl -n kube-system get pods
|
||||||
|
# coredns, local-path-provisioner, metrics-server, traefik : tous Running
|
||||||
|
|
||||||
|
# Chiffrement des Secrets au repos
|
||||||
|
ssh deploy@<app_public_ipv4> 'sudo k3s secrets-encrypt status'
|
||||||
|
# Encryption Status: Enabled
|
||||||
|
|
||||||
|
# Journal d'audit alimenté
|
||||||
|
ssh deploy@<app_public_ipv4> 'sudo tail -2 /var/log/k3s/audit.log | head -c 300'
|
||||||
|
|
||||||
|
# Traefik écoute bien sur 80 et 443
|
||||||
|
ssh deploy@<app_public_ipv4> 'sudo ss -tlnp | grep -E ":(80|443) "'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Composants du cluster
|
||||||
|
|
||||||
|
Depuis votre poste, kubeconfig chargé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod
|
||||||
|
REGISTRY_TOKEN='<jeton Scaleway>' bash scripts/03-install-cluster-addons.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script installe :
|
||||||
|
1. les namespaces `xpeditis-prod` et `monitoring`, avec l'*admission de sécurité
|
||||||
|
des pods* — `restricted` pour l'application, `baseline` pour la supervision
|
||||||
|
(Promtail doit lire les journaux de l'hôte) ;
|
||||||
|
2. **cert-manager** depuis son manifeste officiel épinglé (pas de Helm à
|
||||||
|
maintenir) ;
|
||||||
|
3. le `Secret` d'accès au registre Scaleway (`regcred`) ;
|
||||||
|
4. le `ClusterIssuer` Let's Encrypt — seulement si le secret Cloudflare existe
|
||||||
|
déjà, sinon il vous le rappelle. C'est normal à ce stade : il sera appliqué
|
||||||
|
après [06 — Secrets](./06-secrets-sops.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n cert-manager get pods # 3 pods Running
|
||||||
|
kubectl get ns # xpeditis-prod, monitoring, cert-manager
|
||||||
|
kubectl -n xpeditis-prod get secret regcred
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Éprouver les garde-fous
|
||||||
|
|
||||||
|
Cette étape est facultative mais rassurante : elle vérifie que les protections
|
||||||
|
mordent réellement.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Un pod privilégié doit être REFUSÉ par le namespace restricted
|
||||||
|
kubectl -n xpeditis-prod run test-privilegie --image=alpine --restart=Never \
|
||||||
|
--overrides='{"spec":{"containers":[{"name":"c","image":"alpine","securityContext":{"privileged":true}}]}}' \
|
||||||
|
-- sleep 10
|
||||||
|
# Attendu : "violates PodSecurity restricted:latest"
|
||||||
|
|
||||||
|
# Un pod root doit être REFUSÉ
|
||||||
|
kubectl -n xpeditis-prod run test-root --image=alpine --restart=Never \
|
||||||
|
--overrides='{"spec":{"containers":[{"name":"c","image":"alpine","securityContext":{"runAsUser":0}}]}}' \
|
||||||
|
-- sleep 10
|
||||||
|
# Attendu : refus également
|
||||||
|
```
|
||||||
|
|
||||||
|
Si l'un des deux est **accepté**, les étiquettes du namespace n'ont pas été
|
||||||
|
appliquées :
|
||||||
|
```bash
|
||||||
|
kubectl apply -f infra/prod/k8s/base/00-namespaces.yaml
|
||||||
|
kubectl get ns xpeditis-prod -o jsonpath='{.metadata.labels}' | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ce qu'il ne faut pas faire
|
||||||
|
|
||||||
|
| À éviter | Pourquoi |
|
||||||
|
|---|---|
|
||||||
|
| Ouvrir 6443 à `0.0.0.0/0` | L'API Kubernetes exposée publiquement est une cible permanente. Terraform la restreint à vos IP. |
|
||||||
|
| Mettre le kubeconfig dans un secret GitHub | Un dépôt compromis donnerait le contrôle total du cluster. La CI passe par SSH avec une clé restreinte à un script. |
|
||||||
|
| Installer un tableau de bord Kubernetes | Surface d'attaque considérable pour un gain nul face à `kubectl` et Grafana. |
|
||||||
|
| Exécuter des charges de travail en `hostNetwork` | Seul `node-exporter` le fait, et c'est déjà un compromis assumé. |
|
||||||
|
| Désactiver les `NetworkPolicy` | Elles empêchent un pod compromis de balayer le réseau privé. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Nœud Ready, k3s v1.31.x
|
||||||
|
[ ] secrets-encrypt status = Enabled
|
||||||
|
[ ] Journal d'audit alimenté dans /var/log/k3s/audit.log
|
||||||
|
[ ] Traefik écoute sur 80 et 443, tableau de bord désactivé
|
||||||
|
[ ] cert-manager : 3 pods Running
|
||||||
|
[ ] Namespaces xpeditis-prod (restricted) et monitoring (baseline) créés
|
||||||
|
[ ] Secret regcred présent
|
||||||
|
[ ] Un pod privilégié est refusé
|
||||||
|
[ ] kubeconfig récupéré en chmod 600, hors de Git
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [06 — Secrets SOPS](./06-secrets-sops.md)**
|
||||||
251
docs/mise-en-prod/06-secrets-sops.md
Normal file
251
docs/mise-en-prod/06-secrets-sops.md
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
# 06 — Secrets (SOPS + age)
|
||||||
|
|
||||||
|
**Durée : environ 1 h.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Pourquoi SOPS et pas autre chose
|
||||||
|
|
||||||
|
| Approche | Pourquoi elle a été écartée |
|
||||||
|
|---|---|
|
||||||
|
| Secrets en clair dans le YAML | C'est ce que fait la preprod aujourd'hui. Résultat : mot de passe de base, `JWT_SECRET` et **clé SMTP Brevo** publiés dans l'historique Git. |
|
||||||
|
| Sealed Secrets | Bonne solution, mais elle ne chiffre **que** des `Secret` Kubernetes. Le fichier `.env.data` de db-01 (hors cluster) resterait sans solution. |
|
||||||
|
| Vault / OpenBao | Excellent, et une pièce d'infrastructure de plus à héberger, sauvegarder et desceller après chaque redémarrage. Disproportionné ici. |
|
||||||
|
| Secrets GitHub uniquement | Le dépôt deviendrait le coffre-fort de la production. Un jeton d'accès compromis livrerait les mots de passe de la base. |
|
||||||
|
|
||||||
|
**SOPS + age** couvre les deux mondes avec une seule clé, sans composant à
|
||||||
|
héberger, et produit des différentiels lisibles en revue (seules les valeurs
|
||||||
|
sont chiffrées, pas les noms de clés).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Créer les clés
|
||||||
|
|
||||||
|
### Clé principale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/sops/age
|
||||||
|
age-keygen -o ~/.config/sops/age/keys.txt
|
||||||
|
chmod 600 ~/.config/sops/age/keys.txt
|
||||||
|
grep 'public key' ~/.config/sops/age/keys.txt
|
||||||
|
# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clé de secours
|
||||||
|
|
||||||
|
**Ne sautez pas cette étape.** Sans elle, perdre votre poste signifie perdre
|
||||||
|
définitivement l'accès à tous les secrets de production.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
age-keygen -o /tmp/xpeditis-backup-key.txt
|
||||||
|
cat /tmp/xpeditis-backup-key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois destinations, au moins deux d'entre elles :
|
||||||
|
- imprimée sur papier, dans un coffre ou un lieu physique distinct ;
|
||||||
|
- sur une clé USB chiffrée, rangée ailleurs que votre poste ;
|
||||||
|
- dans un gestionnaire de mots de passe **différent** de celui du quotidien.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
shred -u /tmp/xpeditis-backup-key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Déclarer les deux clés
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod
|
||||||
|
$EDITOR .sops.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacez `AGE_RECIPIENT_PRIMARY` et `AGE_RECIPIENT_BACKUP` par les deux clés
|
||||||
|
publiques (`age1…`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Composer les secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
|
||||||
|
$EDITOR /tmp/secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Générer les valeurs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JWT_SECRET — minimum 32 caractères (validé par Joi au démarrage)
|
||||||
|
openssl rand -base64 64 | tr -d '\n'
|
||||||
|
|
||||||
|
# DOCUMENT_PASSWORD_SECRET — minimum 16
|
||||||
|
openssl rand -base64 32 | tr -d '\n'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Correspondances à ne pas manquer
|
||||||
|
|
||||||
|
| Secret Kubernetes | Doit être identique à |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_PASSWORD` | `POSTGRES_PASSWORD` de `/opt/xpeditis/data-node/.env.data` |
|
||||||
|
| `REDIS_PASSWORD` | `REDIS_PASSWORD` du même fichier |
|
||||||
|
|
||||||
|
Une divergence produit un backend qui démarre puis boucle en
|
||||||
|
`CrashLoopBackOff` avec `password authentication failed`.
|
||||||
|
|
||||||
|
### Trois pièges
|
||||||
|
|
||||||
|
**1. Les identifiants de tarif Stripe.** Le code lit
|
||||||
|
`STRIPE_SILVER_*`, `STRIPE_GOLD_*`, `STRIPE_PLATINIUM_*`. Le stack de preprod
|
||||||
|
définit `STRIPE_STARTER_*`, `STRIPE_PRO_*`, `STRIPE_ENTERPRISE_*` — **des noms
|
||||||
|
que personne ne lit**, si bien qu'en preprod ces identifiants sont en réalité
|
||||||
|
absents. Utilisez les noms attendus par le code, ceux du gabarit.
|
||||||
|
|
||||||
|
**2. `DOCUMENT_PASSWORD_SECRET`.** S'il est vide, le code retombe sur
|
||||||
|
`JWT_SECRET`. Une rotation ultérieure du `JWT_SECRET` rendrait alors illisibles
|
||||||
|
tous les documents transporteurs déjà émis. **Définissez-le explicitement, et
|
||||||
|
ne le changez plus jamais** sans plan de migration.
|
||||||
|
|
||||||
|
**3. `SWAGGER_USERNAME` / `SWAGGER_PASSWORD`.** Laissez-les **vides**. `main.ts`
|
||||||
|
désactive alors totalement `/api/docs` en production. Les renseigner expose la
|
||||||
|
documentation complète de l'API derrière une simple authentification Basic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Chiffrer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod
|
||||||
|
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
|
||||||
|
shred -u /tmp/secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérifier avant de commiter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Les noms de clés restent lisibles, les valeurs sont chiffrées
|
||||||
|
grep -A3 'DATABASE_PASSWORD' k8s/base/03-secrets.sops.yaml
|
||||||
|
# DATABASE_PASSWORD: ENC[AES256_GCM,data:...,tag:...]
|
||||||
|
|
||||||
|
# Aucune valeur en clair ne subsiste
|
||||||
|
grep -c 'ENC\[' k8s/base/03-secrets.sops.yaml # doit être > 20
|
||||||
|
|
||||||
|
# Le déchiffrement fonctionne
|
||||||
|
sops -d k8s/base/03-secrets.sops.yaml | head -20
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make secrets-check # refuse tout fichier sensible non ignoré
|
||||||
|
git add k8s/base/03-secrets.sops.yaml
|
||||||
|
git commit -m "prod: secrets chiffres SOPS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Appliquer sur le cluster
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
make secrets-apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Le déchiffré ne touche jamais le disque : `sops` écrit sur la sortie standard,
|
||||||
|
`kubectl` lit sur l'entrée standard.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod get secrets
|
||||||
|
kubectl -n monitoring get secrets
|
||||||
|
kubectl -n cert-manager get secret cloudflare-api-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Appliquez maintenant le `ClusterIssuer`, qui attendait ce secret :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/cluster/cluster-issuer.yaml
|
||||||
|
kubectl get clusterissuer
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Modifier un secret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops k8s/base/03-secrets.sops.yaml # ouvre votre éditeur, rechiffre en sortie
|
||||||
|
make secrets-apply
|
||||||
|
|
||||||
|
# Indispensable : modifier un Secret ne redémarre pas les pods
|
||||||
|
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rotation
|
||||||
|
|
||||||
|
| Secret | Fréquence | Effet |
|
||||||
|
|---|---|---|
|
||||||
|
| `JWT_SECRET` | annuelle, ou immédiatement en cas de fuite | **Déconnecte tous les utilisateurs.** À faire hors heures ouvrées. |
|
||||||
|
| `DATABASE_PASSWORD` | annuelle | Changer d'abord dans PostgreSQL (`ALTER ROLE`), puis dans le Secret, puis redémarrer. Brève coupure. |
|
||||||
|
| `REDIS_PASSWORD` | annuelle | Vider le cache est sans conséquence, les sessions le sont davantage : prévoyez une déconnexion. |
|
||||||
|
| `STRIPE_SECRET_KEY` | sur incident | Rotation depuis le tableau de bord Stripe. |
|
||||||
|
| Clé SMTP Brevo | sur incident | **À faire maintenant** : celle de preprod est publiée. |
|
||||||
|
| `DOCUMENT_PASSWORD_SECRET` | **jamais** | Rendrait illisibles les documents déjà émis. |
|
||||||
|
| Clé age | tous les 2 ans | `sops updatekeys` sur tous les fichiers après avoir modifié `.sops.yaml`. |
|
||||||
|
|
||||||
|
### Rotation du mot de passe PostgreSQL, sans surprise
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Nouveau mot de passe côté base
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && \
|
||||||
|
sudo docker compose exec -T -u postgres postgres \
|
||||||
|
psql -c "ALTER ROLE xpeditis WITH PASSWORD '"'"'<nouveau>'"'"';"'
|
||||||
|
|
||||||
|
# 2. Le fichier .env.data de db-01 (postgres-exporter l'utilise)
|
||||||
|
ssh deploy@<db_ip> 'sudo $EDITOR /opt/xpeditis/data-node/.env.data'
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && \
|
||||||
|
sudo docker compose --env-file .env.data up -d postgres-exporter'
|
||||||
|
|
||||||
|
# 3. Le Secret Kubernetes
|
||||||
|
sops k8s/base/03-secrets.sops.yaml
|
||||||
|
make secrets-apply
|
||||||
|
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
|
||||||
|
|
||||||
|
# 4. La version chiffrée du .env.data
|
||||||
|
sops -e --input-type dotenv --output-type dotenv <fichier> > data-node/data-node.sops.env
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Perte de la clé age
|
||||||
|
|
||||||
|
Si vous perdez la clé principale **et** la clé de secours, les secrets chiffrés
|
||||||
|
dans Git sont définitivement illisibles. La production continue de tourner (les
|
||||||
|
`Secret` sont déjà dans le cluster), mais vous ne pouvez plus les modifier ni
|
||||||
|
les redéployer ailleurs.
|
||||||
|
|
||||||
|
Récupération d'urgence, tant que le cluster fonctionne :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extraire les valeurs depuis le cluster
|
||||||
|
kubectl -n xpeditis-prod get secret xpeditis-backend-secrets -o json \
|
||||||
|
| jq -r '.data | to_entries[] | "\(.key)=\(.value|@base64d)"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis recréez une paire de clés age et rechiffrez. Faites-le **immédiatement** :
|
||||||
|
si le cluster tombe entre-temps, les secrets sont perdus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Clé age principale créée, chmod 600
|
||||||
|
[ ] Clé de secours créée et rangée dans DEUX endroits physiques distincts
|
||||||
|
[ ] .sops.yaml renseigné avec les deux clés publiques
|
||||||
|
[ ] 03-secrets.sops.yaml créé, > 20 valeurs ENC[…]
|
||||||
|
[ ] DATABASE_PASSWORD et REDIS_PASSWORD identiques à ceux de db-01
|
||||||
|
[ ] Identifiants de tarif Stripe aux noms SILVER / GOLD / PLATINIUM
|
||||||
|
[ ] DOCUMENT_PASSWORD_SECRET défini explicitement
|
||||||
|
[ ] SWAGGER_USERNAME et SWAGGER_PASSWORD laissés vides
|
||||||
|
[ ] Secrets appliqués sur le cluster
|
||||||
|
[ ] ClusterIssuer letsencrypt-prod créé
|
||||||
|
[ ] make secrets-check passe
|
||||||
|
[ ] /tmp/secrets.yaml effacé avec shred
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [07 — Stockage objet](./07-stockage-objet-s3.md)**
|
||||||
173
docs/mise-en-prod/07-stockage-objet-s3.md
Normal file
173
docs/mise-en-prod/07-stockage-objet-s3.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# 07 — Stockage objet (Hetzner Object Storage)
|
||||||
|
|
||||||
|
**Durée : environ 45 min.**
|
||||||
|
|
||||||
|
Hetzner Object Storage remplace MinIO en production. C'est un service
|
||||||
|
compatible S3 : **aucune ligne de code applicatif ne change**, seules les
|
||||||
|
variables `AWS_S3_ENDPOINT`, `AWS_REGION` et les clés diffèrent.
|
||||||
|
|
||||||
|
| | MinIO auto-hébergé | Hetzner Object Storage |
|
||||||
|
|---|---|---|
|
||||||
|
| Coût | « gratuit » + le disque + votre temps | ~6 €/To/mois |
|
||||||
|
| À sauvegarder | oui, un volume de plus | non, réplication incluse |
|
||||||
|
| CVE à suivre | oui | non |
|
||||||
|
| Console à protéger | oui | non |
|
||||||
|
| Perte si app-01 brûle | **totale** | aucune |
|
||||||
|
|
||||||
|
Le dernier point suffit à trancher : les connaissements et confirmations de
|
||||||
|
réservation sont des documents contractuels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Créer les buckets
|
||||||
|
|
||||||
|
Console Hetzner → projet `xpeditis-prod` → **Object Storage** → région `fsn1`.
|
||||||
|
|
||||||
|
Créez **deux** buckets :
|
||||||
|
|
||||||
|
| Bucket | Contenu | Visibilité |
|
||||||
|
|---|---|---|
|
||||||
|
| `xpeditis-prod-documents` | PDF, connaissements, imports CSV | **Privé** |
|
||||||
|
| `xpeditis-prod-pgbackup` | Sauvegardes WAL-G | **Privé** |
|
||||||
|
|
||||||
|
> **Deux buckets, deux jeux de clés.** Si les clés applicatives fuitent (fuite
|
||||||
|
> de secret, faille d'injection), l'attaquant atteint les documents mais **pas
|
||||||
|
> les sauvegardes**. C'est précisément ce qui permet de se relever d'un
|
||||||
|
> rançongiciel. Ne mutualisez pas.
|
||||||
|
|
||||||
|
Vérifiez que les deux sont bien en **Private**. Un bucket public exposerait des
|
||||||
|
documents commerciaux nominatifs à toute personne devinant une URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Créer les clés d'accès
|
||||||
|
|
||||||
|
**Object Storage → Credentials → Generate credentials**, deux fois :
|
||||||
|
|
||||||
|
| Nom | Bucket | Destination |
|
||||||
|
|---|---|---|
|
||||||
|
| `xpeditis-app` | `xpeditis-prod-documents` | Secret Kubernetes `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` |
|
||||||
|
| `xpeditis-walg` | `xpeditis-prod-pgbackup` | `.env.data` de db-01, `WALG_ACCESS_KEY_ID` / `WALG_SECRET_ACCESS_KEY` |
|
||||||
|
|
||||||
|
Le secret n'est affiché **qu'une fois**. Copiez-le immédiatement.
|
||||||
|
|
||||||
|
> Hetzner Object Storage ne propose pas encore de politique par bucket aussi
|
||||||
|
> fine qu'AWS IAM. La séparation repose donc sur l'usage de deux jeux de clés
|
||||||
|
> distincts, chacun n'étant présent qu'à un seul endroit. Ne copiez jamais les
|
||||||
|
> clés WAL-G dans un `Secret` Kubernetes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration applicative
|
||||||
|
|
||||||
|
Déjà en place dans `infra/prod/k8s/base/02-configmap-backend.yaml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
AWS_REGION: "fsn1"
|
||||||
|
AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com"
|
||||||
|
AWS_S3_BUCKET: "xpeditis-prod-documents"
|
||||||
|
```
|
||||||
|
|
||||||
|
Les clés vont dans le `Secret` ([06](./06-secrets-sops.md)) :
|
||||||
|
```yaml
|
||||||
|
AWS_ACCESS_KEY_ID: "<clé xpeditis-app>"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "<secret xpeditis-app>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Et WAL-G dans `.env.data` sur db-01 ([04](./04-noeud-donnees.md)) :
|
||||||
|
```
|
||||||
|
WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup
|
||||||
|
WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
WALG_S3_REGION=fsn1
|
||||||
|
WALG_ACCESS_KEY_ID=<clé xpeditis-walg>
|
||||||
|
WALG_SECRET_ACCESS_KEY=<secret xpeditis-walg>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vérifier
|
||||||
|
|
||||||
|
Avec le client AWS ou `s3cmd` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AWS_ACCESS_KEY_ID='<clé xpeditis-app>'
|
||||||
|
export AWS_SECRET_ACCESS_KEY='<secret xpeditis-app>'
|
||||||
|
|
||||||
|
# Lister
|
||||||
|
aws --endpoint-url https://fsn1.your-objectstorage.com s3 ls s3://xpeditis-prod-documents/
|
||||||
|
|
||||||
|
# Écrire puis relire
|
||||||
|
echo "test $(date -Is)" > /tmp/test.txt
|
||||||
|
aws --endpoint-url https://fsn1.your-objectstorage.com s3 cp /tmp/test.txt s3://xpeditis-prod-documents/
|
||||||
|
aws --endpoint-url https://fsn1.your-objectstorage.com s3 cp s3://xpeditis-prod-documents/test.txt -
|
||||||
|
aws --endpoint-url https://fsn1.your-objectstorage.com s3 rm s3://xpeditis-prod-documents/test.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Le bucket est-il vraiment privé ?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI https://fsn1.your-objectstorage.com/xpeditis-prod-documents/test.txt | head -1
|
||||||
|
```
|
||||||
|
Attendu : `HTTP/1.1 403 Forbidden`. Si vous obtenez `200`, le bucket est
|
||||||
|
public : corrigez immédiatement.
|
||||||
|
|
||||||
|
### Les clés sont-elles bien cloisonnées ?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Les clés applicatives ne doivent PAS voir le bucket de sauvegarde
|
||||||
|
aws --endpoint-url https://fsn1.your-objectstorage.com s3 ls s3://xpeditis-prod-pgbackup/
|
||||||
|
```
|
||||||
|
Un refus est le résultat attendu. S'il réussit, vous avez utilisé les mêmes
|
||||||
|
clés pour les deux : régénérez-en un jeu séparé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Durée de conservation
|
||||||
|
|
||||||
|
Une règle de cycle de vie évite que les documents s'accumulent indéfiniment —
|
||||||
|
utile pour la facture comme pour le RGPD ([16](./16-rgpd-conformite.md)).
|
||||||
|
|
||||||
|
Attention : les documents contractuels maritimes ont des obligations de
|
||||||
|
conservation longues (généralement 10 ans en France pour les pièces
|
||||||
|
comptables). **Ne posez pas de règle de suppression automatique sur
|
||||||
|
`xpeditis-prod-documents`** sans validation juridique.
|
||||||
|
|
||||||
|
Sur `xpeditis-prod-pgbackup`, en revanche, la purge est gérée par WAL-G
|
||||||
|
(`wal-g delete retain FULL 7`, dans `pg-backup.sh`). N'ajoutez pas de règle de
|
||||||
|
cycle de vie côté bucket : les deux mécanismes se contrediraient et vous
|
||||||
|
risqueriez de supprimer des WAL encore nécessaires à une restauration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Et le CDN ?
|
||||||
|
|
||||||
|
Le fichier de prévisions retient Bunny.net (0,005 €/Go, ~1 €/mois en phase 1)
|
||||||
|
comme CDN si Hetzner est choisi.
|
||||||
|
|
||||||
|
**Ce n'est pas nécessaire au lancement.** Cloudflare met déjà en cache les
|
||||||
|
ressources statiques de Next.js (`/_next/static/*`) gratuitement, et le trafic
|
||||||
|
de la phase 1 (10 Go/mois selon les hypothèses) ne justifie pas un service
|
||||||
|
supplémentaire.
|
||||||
|
|
||||||
|
Reconsidérez la question quand :
|
||||||
|
- le trafic sortant dépasse 500 Go/mois ; ou
|
||||||
|
- des utilisateurs hors d'Europe se plaignent de la latence ; ou
|
||||||
|
- vous servez des documents volumineux directement depuis Object Storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Bucket xpeditis-prod-documents créé, PRIVÉ
|
||||||
|
[ ] Bucket xpeditis-prod-pgbackup créé, PRIVÉ
|
||||||
|
[ ] Deux jeux de clés DISTINCTS générés
|
||||||
|
[ ] Écriture / lecture / suppression testées sur le bucket documents
|
||||||
|
[ ] Accès HTTP anonyme : 403
|
||||||
|
[ ] Les clés applicatives ne peuvent PAS lire le bucket de sauvegarde
|
||||||
|
[ ] Clés applicatives dans le Secret Kubernetes
|
||||||
|
[ ] Clés WAL-G uniquement dans .env.data de db-01
|
||||||
|
[ ] Aucune règle de cycle de vie sur le bucket de sauvegarde
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [08 — DNS, TLS, Cloudflare](./08-dns-tls-cloudflare.md)**
|
||||||
294
docs/mise-en-prod/08-dns-tls-cloudflare.md
Normal file
294
docs/mise-en-prod/08-dns-tls-cloudflare.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# 08 — DNS, TLS et Cloudflare
|
||||||
|
|
||||||
|
**Durée : environ 1 h 30**, dont des délais d'attente incompressibles.
|
||||||
|
|
||||||
|
La référence complète des réglages Cloudflare est dans
|
||||||
|
[`infra/prod/cloudflare/README.md`](../../infra/prod/cloudflare/README.md).
|
||||||
|
Ce document donne l'ordre d'exécution et les vérifications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ordre à respecter
|
||||||
|
|
||||||
|
L'ordre compte : émettre un certificat avant que le DNS ne réponde échoue, et
|
||||||
|
Let's Encrypt limite la production à **5 échecs par heure**.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Enregistrements DNS chez Cloudflare (propagation : quelques minutes)
|
||||||
|
2. Réglages SSL/TLS Cloudflare
|
||||||
|
3. Certificat de TEST (staging) ← valide la chaîne DNS-01
|
||||||
|
4. Certificat de PRODUCTION
|
||||||
|
5. Vérifications
|
||||||
|
6. Règles WAF et cache
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Enregistrements DNS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod/terraform && terraform output dns_records_to_create
|
||||||
|
```
|
||||||
|
|
||||||
|
Créez dans Cloudflare, **tous proxifiés (nuage orange)** :
|
||||||
|
|
||||||
|
| Type | Nom | Valeur |
|
||||||
|
|---|---|---|
|
||||||
|
| A | `xpeditis.com` | `<app_public_ipv4>` |
|
||||||
|
| A | `www` | `<app_public_ipv4>` |
|
||||||
|
| A | `app` | `<app_public_ipv4>` |
|
||||||
|
| A | `api` | `<app_public_ipv4>` |
|
||||||
|
| A | `grafana` | `<app_public_ipv4>` |
|
||||||
|
|
||||||
|
**Aucun enregistrement ne pointe vers db-01.**
|
||||||
|
|
||||||
|
> Le proxy n'est pas optionnel : le firewall Hetzner n'accepte 80/443 que depuis
|
||||||
|
> les rangs Cloudflare. Un enregistrement en nuage gris (DNS only) donnerait un
|
||||||
|
> domaine injoignable — comportement voulu, mais déroutant si on l'a oublié.
|
||||||
|
|
||||||
|
### E-mail — à faire maintenant
|
||||||
|
|
||||||
|
```
|
||||||
|
TXT xpeditis.com v=spf1 include:spf.brevo.com -all
|
||||||
|
TXT mail._domainkey <clé DKIM fournie par Brevo>
|
||||||
|
TXT _dmarc v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques
|
||||||
|
transporteurs partent en indésirables. C'est un défaut de production silencieux
|
||||||
|
et coûteux : personne ne se plaint, les clients pensent simplement que le
|
||||||
|
service ne marche pas.
|
||||||
|
|
||||||
|
### Vérifier
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig +short app.xpeditis.com # doit renvoyer des IP Cloudflare (104.x, 172.6x…)
|
||||||
|
dig +short TXT xpeditis.com
|
||||||
|
dig +short TXT _dmarc.xpeditis.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Réglages SSL/TLS Cloudflare
|
||||||
|
|
||||||
|
**SSL/TLS → Overview**
|
||||||
|
|
||||||
|
| Réglage | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Mode de chiffrement | **Full (strict)** |
|
||||||
|
| Always Use HTTPS | Activé |
|
||||||
|
| Minimum TLS Version | 1.2 |
|
||||||
|
| TLS 1.3 | Activé |
|
||||||
|
| Automatic HTTPS Rewrites | Activé |
|
||||||
|
| HSTS | **Pas encore** — voir §6 |
|
||||||
|
|
||||||
|
> **« Full (strict) » et rien d'autre.** En mode *Flexible*, Cloudflare parle en
|
||||||
|
> clair à votre serveur : le cadenas s'affiche dans le navigateur alors que le
|
||||||
|
> trafic circule en clair sur Internet. C'est pire que pas de HTTPS du tout,
|
||||||
|
> puisque personne ne s'en aperçoit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Certificat de test
|
||||||
|
|
||||||
|
On valide d'abord la chaîne DNS-01 avec l'émetteur *staging*, qui n'a pas de
|
||||||
|
quota serré.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
|
||||||
|
kubectl -n xpeditis-prod patch certificate xpeditis-wildcard --type=merge \
|
||||||
|
-p '{"spec":{"issuerRef":{"name":"letsencrypt-staging","kind":"ClusterIssuer"}}}'
|
||||||
|
|
||||||
|
kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls --ignore-not-found
|
||||||
|
|
||||||
|
# Suivre l'émission
|
||||||
|
kubectl -n xpeditis-prod get certificate -w
|
||||||
|
kubectl -n xpeditis-prod describe certificaterequest
|
||||||
|
kubectl -n cert-manager logs -l app=cert-manager --tail=50 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Deux à cinq minutes (propagation de l'enregistrement TXT `_acme-challenge`).
|
||||||
|
|
||||||
|
`READY: True` signifie que le jeton Cloudflare, les permissions et la zone sont
|
||||||
|
corrects. Si l'émission échoue :
|
||||||
|
|
||||||
|
| Message | Cause |
|
||||||
|
|---|---|
|
||||||
|
| `Cloudflare API error 10000` | Jeton invalide ou permissions insuffisantes (`Zone / DNS / Edit` requis). |
|
||||||
|
| `could not find zone` | Le jeton ne couvre pas `xpeditis.com`, ou la zone n'est pas active. |
|
||||||
|
| `propagation check failed` | Attendre. Si cela persiste au-delà de 10 min, vérifier qu'aucun autre DNS ne fait autorité. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Certificat de production
|
||||||
|
|
||||||
|
Une fois le test au vert :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod patch certificate xpeditis-wildcard --type=merge \
|
||||||
|
-p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}'
|
||||||
|
kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls --ignore-not-found
|
||||||
|
|
||||||
|
kubectl -n monitoring patch certificate grafana-tls --type=merge \
|
||||||
|
-p '{"spec":{"issuerRef":{"name":"letsencrypt-prod","kind":"ClusterIssuer"}}}'
|
||||||
|
kubectl -n monitoring delete secret grafana-tls --ignore-not-found
|
||||||
|
|
||||||
|
kubectl get certificate -A -w
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifiez l'émetteur réel du certificat servi :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo | openssl s_client -connect api.xpeditis.com:443 -servername api.xpeditis.com 2>/dev/null \
|
||||||
|
| openssl x509 -noout -issuer -subject -dates
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : `issuer=C=US, O=Let's Encrypt, CN=R11` (ou équivalent).
|
||||||
|
Si vous lisez `(STAGING)`, le certificat de test est encore en place :
|
||||||
|
supprimez le `Secret` et relancez.
|
||||||
|
|
||||||
|
> Avec Cloudflare en proxy, le navigateur voit le certificat **Cloudflare**, pas
|
||||||
|
> le vôtre. La commande ci-dessus interroge Cloudflare. Pour vérifier le
|
||||||
|
> certificat d'origine, il faut se connecter depuis app-01 :
|
||||||
|
> ```bash
|
||||||
|
> ssh deploy@<app_ip> \
|
||||||
|
> "echo | openssl s_client -connect 127.0.0.1:443 -servername api.xpeditis.com 2>/dev/null | openssl x509 -noout -issuer -dates"
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. HSTS
|
||||||
|
|
||||||
|
**À faire seulement quand tous les sous-domaines servent du HTTPS valide.**
|
||||||
|
|
||||||
|
Traefik pose déjà l'en-tête (`k8s/base/08-traefik-middlewares.yaml`,
|
||||||
|
`stsSeconds: 31536000`). Activer HSTS **aussi** chez Cloudflare le fait
|
||||||
|
appliquer avant même que la requête n'atteigne le serveur.
|
||||||
|
|
||||||
|
**SSL/TLS → Edge Certificates → HTTP Strict Transport Security → Enable**
|
||||||
|
|
||||||
|
| Réglage | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Max Age | 12 mois |
|
||||||
|
| Include subdomains | Oui |
|
||||||
|
| Preload | Oui |
|
||||||
|
| No-Sniff | Oui |
|
||||||
|
|
||||||
|
> **C'est irréversible pendant un an.** Une fois HSTS envoyé, les navigateurs
|
||||||
|
> refuseront tout accès HTTP à `xpeditis.com` et à ses sous-domaines pendant la
|
||||||
|
> durée annoncée, même si vous désactivez le réglage. Un sous-domaine qui
|
||||||
|
> n'aurait pas de certificat valide deviendrait inaccessible sans recours.
|
||||||
|
>
|
||||||
|
> Commencez par `Max Age = 1 mois`, sans *preload*. Passez à 12 mois et
|
||||||
|
> *preload* après deux semaines sans incident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Règles WAF et cache
|
||||||
|
|
||||||
|
Appliquez les règles décrites dans
|
||||||
|
[`infra/prod/cloudflare/README.md`](../../infra/prod/cloudflare/README.md) :
|
||||||
|
|
||||||
|
1. **Bypass cache sur `api.xpeditis.com/*`** — la plus importante. Une réponse
|
||||||
|
d'API mise en cache servirait les données d'un utilisateur à un autre.
|
||||||
|
2. Blocage de `/api/docs`.
|
||||||
|
3. Limitation de débit sur `/api/v1/auth/login` (10 requêtes/minute/IP).
|
||||||
|
4. Restriction du webhook Stripe aux IP de Stripe.
|
||||||
|
5. Cache long sur `/_next/static/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Vérifications finales
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Les redirections
|
||||||
|
curl -sI http://xpeditis.com | head -3 # 301 → https
|
||||||
|
curl -sI https://www.xpeditis.com | head -1 # 200
|
||||||
|
curl -sI https://app.xpeditis.com | head -1 # 200
|
||||||
|
curl -sI https://api.xpeditis.com/api/v1/health | head -1 # 200
|
||||||
|
|
||||||
|
# Les en-têtes de sécurité
|
||||||
|
curl -sI https://app.xpeditis.com | grep -iE 'strict-transport|x-frame|x-content-type|referrer-policy'
|
||||||
|
|
||||||
|
# L'API n'est pas mise en cache
|
||||||
|
curl -sI https://api.xpeditis.com/api/v1/health | grep -i 'cf-cache-status'
|
||||||
|
# Attendu : BYPASS ou DYNAMIC, jamais HIT
|
||||||
|
|
||||||
|
# L'origine est-elle contournable ?
|
||||||
|
curl -sS --max-time 5 --connect-to app.xpeditis.com:443:<app_public_ipv4>:443 \
|
||||||
|
https://app.xpeditis.com/ 2>&1 | head -2
|
||||||
|
# Attendu : timeout — le firewall Hetzner bloque tout ce qui n'est pas Cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Analyse externe : [ssllabs.com/ssltest](https://www.ssllabs.com/ssltest/) sur
|
||||||
|
`app.xpeditis.com` — visez A ou A+.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Dépannage
|
||||||
|
|
||||||
|
### Le certificat ne s'émet pas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod describe certificate xpeditis-wildcard
|
||||||
|
kubectl -n xpeditis-prod get certificaterequest,order,challenge
|
||||||
|
kubectl -n cert-manager logs -l app=cert-manager --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `Challenge` indique précisément à quelle étape cela bloque.
|
||||||
|
|
||||||
|
### Le certificat approche de l'expiration
|
||||||
|
|
||||||
|
cert-manager renouvelle 30 jours avant l'échéance. L'alerte
|
||||||
|
`CertificatBientotExpire` se déclenche à 15 jours — c'est-à-dire uniquement si
|
||||||
|
le renouvellement automatique a cessé de fonctionner.
|
||||||
|
|
||||||
|
Forcer un renouvellement :
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod delete secret xpeditis-wildcard-tls
|
||||||
|
# cert-manager réémet dans la minute
|
||||||
|
```
|
||||||
|
|
||||||
|
### « Too many certificates already issued »
|
||||||
|
|
||||||
|
Vous avez atteint la limite Let's Encrypt (50 par domaine et par semaine).
|
||||||
|
Elle se réinitialise glissant sur 7 jours. En attendant, utilisez
|
||||||
|
`letsencrypt-staging` — et c'est exactement pourquoi la validation se fait
|
||||||
|
d'abord en staging.
|
||||||
|
|
||||||
|
### Erreur 521 / 522 chez Cloudflare
|
||||||
|
|
||||||
|
Cloudflare n'atteint pas l'origine.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<app_ip> 'sudo systemctl status k3s; sudo ss -tlnp | grep -E ":(80|443) "'
|
||||||
|
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
Cause fréquente : les rangs d'IP Cloudflare ont changé.
|
||||||
|
```bash
|
||||||
|
bash infra/prod/scripts/refresh-cloudflare-ips.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] 5 enregistrements A créés, tous proxifiés
|
||||||
|
[ ] Aucun enregistrement ne pointe vers db-01
|
||||||
|
[ ] SPF, DKIM, DMARC publiés
|
||||||
|
[ ] Mode SSL : Full (strict)
|
||||||
|
[ ] Certificat de test émis avec succès (chaîne DNS-01 validée)
|
||||||
|
[ ] Certificat de production émis, Ready: True, émetteur non-staging
|
||||||
|
[ ] HTTP redirige en 301 vers HTTPS
|
||||||
|
[ ] En-têtes de sécurité présents
|
||||||
|
[ ] cf-cache-status = BYPASS sur l'API
|
||||||
|
[ ] L'origine n'est pas joignable en contournant Cloudflare
|
||||||
|
[ ] Règles WAF appliquées
|
||||||
|
[ ] Note SSL Labs ≥ A
|
||||||
|
[ ] HSTS activé (après vérification, max-age court d'abord)
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [09 — Déploiement applicatif](./09-deploiement-application.md)**
|
||||||
337
docs/mise-en-prod/09-deploiement-application.md
Normal file
337
docs/mise-en-prod/09-deploiement-application.md
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
# 09 — Déploiement de l'application
|
||||||
|
|
||||||
|
**Durée : environ 2 h.** Premier déploiement, réalisé à la main. L'automatisation
|
||||||
|
vient à l'étape suivante — on ne débogue pas un premier déploiement à travers
|
||||||
|
une chaîne CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Le premier administrateur
|
||||||
|
|
||||||
|
La migration `1730000000007-SeedTestUsers` créait trois comptes dont le mot de
|
||||||
|
passe est écrit **en clair dans le dépôt** — dont `admin@xpeditis.com`, rôle
|
||||||
|
ADMIN, mot de passe `Password123!`. Sur une base de production neuve, appliquer
|
||||||
|
les migrations créait donc un administrateur aux identifiants publics.
|
||||||
|
|
||||||
|
**Trois mécanismes remplacent cela**, et fonctionnent sans intervention :
|
||||||
|
|
||||||
|
| Migration | Effet |
|
||||||
|
|---|---|
|
||||||
|
| `1730000000007-SeedTestUsers` (modifiée) | **Ne s'exécute plus** quand `NODE_ENV=production`. Les comptes ne sont jamais créés. |
|
||||||
|
| `1756000000000-NeutralizeSeedAccountsInProduction` | Filet de sécurité : si ces comptes existent malgré tout (base migrée avant la garde, restauration ancienne), ils sont renommés, rendus inauthentifiables et désactivés. La migration **échoue** si le nettoyage est incomplet. |
|
||||||
|
| `1756000000001-BootstrapAdminFromEnv` | Crée **votre** administrateur à partir de `BOOTSTRAP_ADMIN_EMAIL`. |
|
||||||
|
|
||||||
|
### Comment votre mot de passe est défini
|
||||||
|
|
||||||
|
Dans le cas nominal — `BOOTSTRAP_ADMIN_EMAIL` renseigné dans le ConfigMap,
|
||||||
|
`BOOTSTRAP_ADMIN_PASSWORD_HASH` laissé **vide** — le compte est créé avec le
|
||||||
|
hash Argon2id d'un secret aléatoire immédiatement perdu. Le compte existe, il
|
||||||
|
est actif, mais **aucun mot de passe ne peut y correspondre**. Vous définissez
|
||||||
|
le vôtre via « mot de passe oublié », qui envoie un jeton à usage unique,
|
||||||
|
valable une heure, stocké haché en base.
|
||||||
|
|
||||||
|
Conséquence : **aucun secret n'existe nulle part** — ni dans Git, ni dans le
|
||||||
|
Secret Kubernetes, ni dans l'historique du shell, ni dans les journaux de
|
||||||
|
migration. Il n'y a rien à faire fuiter. Effet de bord utile : la réception du
|
||||||
|
courriel prouve que la chaîne SMTP fonctionne.
|
||||||
|
|
||||||
|
> **Variante, si SMTP n'est pas encore opérationnel.** Générez un hash hors
|
||||||
|
> ligne (`node apps/backend/scripts/setup/generate-admin-hash.js`) et placez-le
|
||||||
|
> dans `BOOTSTRAP_ADMIN_PASSWORD_HASH`, côté **Secret** et jamais ConfigMap. Un
|
||||||
|
> hash reste attaquable hors ligne : changez le mot de passe dès la première
|
||||||
|
> connexion, puis retirez la variable et réappliquez le Secret. Un mot de passe
|
||||||
|
> en clair placé dans cette variable fait échouer la migration.
|
||||||
|
|
||||||
|
Garde-fous : la migration ne fait rien s'il existe déjà un administrateur actif
|
||||||
|
— elle ne peut donc pas en créer un second lors d'un déploiement ultérieur. Et
|
||||||
|
si un compte porte déjà cette adresse, il est promu ADMIN **sans que son mot de
|
||||||
|
passe ne soit touché**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Construire les premières images
|
||||||
|
|
||||||
|
La chaîne de preprod produit les images `preprod-<sha>`. Pour le premier
|
||||||
|
déploiement, on les fabrique à la main.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SHA=$(git rev-parse --short=7 HEAD)
|
||||||
|
export REGISTRY=rg.fr-par.scw.cloud/weworkstudio
|
||||||
|
|
||||||
|
docker login "$REGISTRY" -u nologin -p '<REGISTRY_TOKEN>'
|
||||||
|
|
||||||
|
# Backend — aucune variable de build : tout est lu au démarrage
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t "$REGISTRY/xpeditis-backend:prod-$SHA" \
|
||||||
|
-f apps/backend/Dockerfile apps/backend --push
|
||||||
|
|
||||||
|
# Frontend — les URLs sont FIGÉES ici, pas à l'exécution
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
--build-arg NEXT_PUBLIC_API_URL=https://api.xpeditis.com \
|
||||||
|
--build-arg NEXT_PUBLIC_APP_URL=https://app.xpeditis.com \
|
||||||
|
-t "$REGISTRY/xpeditis-frontend:prod-$SHA" \
|
||||||
|
-f apps/frontend/Dockerfile apps/frontend --push
|
||||||
|
|
||||||
|
# Log exporter
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t "$REGISTRY/xpeditis-log-exporter:prod-$SHA" \
|
||||||
|
-f apps/log-exporter/Dockerfile apps/log-exporter --push
|
||||||
|
```
|
||||||
|
|
||||||
|
> **`next.config.js` fige `NEXT_PUBLIC_API_URL` au moment du build.** Une image
|
||||||
|
> construite avec l'URL de preprod appellera `api.preprod.xpeditis.com` en
|
||||||
|
> production, quelles que soient les variables injectées dans le pod. C'est
|
||||||
|
> pour cette raison que le frontend est **reconstruit** pour la production et
|
||||||
|
> jamais promu depuis la preprod.
|
||||||
|
|
||||||
|
Vérifiez que l'URL de preprod n'a pas fuité dans le bundle :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CID=$(docker create "$REGISTRY/xpeditis-frontend:prod-$SHA")
|
||||||
|
docker cp "$CID:/app/.next" /tmp/next-check && docker rm "$CID"
|
||||||
|
grep -rl "api.preprod.xpeditis.com" /tmp/next-check && echo "PROBLEME" || echo "OK"
|
||||||
|
rm -rf /tmp/next-check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Appliquer la configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
cd infra/prod
|
||||||
|
|
||||||
|
kubectl apply -f k8s/base/00-namespaces.yaml
|
||||||
|
kubectl apply -f k8s/base/01-limits.yaml
|
||||||
|
kubectl apply -f k8s/base/02-configmap-backend.yaml
|
||||||
|
kubectl apply -f k8s/base/08-traefik-middlewares.yaml
|
||||||
|
kubectl apply -f k8s/base/10-network-policies.yaml
|
||||||
|
kubectl apply -f k8s/base/11-certificate.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Relisez la configuration avant d'aller plus loin :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod get cm xpeditis-backend-config -o yaml | grep -E 'DATABASE_HOST|DATABASE_SSL|COOKIE_DOMAIN|CORS_ORIGIN|AWS_S3'
|
||||||
|
```
|
||||||
|
|
||||||
|
Cinq valeurs à ne pas rater :
|
||||||
|
- `DATABASE_SSL: "true"` — `pg_hba` n'accepte que `hostssl` ;
|
||||||
|
- `COOKIE_DOMAIN: ".xpeditis.com"` — avec le point initial ;
|
||||||
|
- `CORS_ORIGIN` — doit contenir **exactement** les origines du frontend
|
||||||
|
(`credentials: true` interdit le joker `*`) ;
|
||||||
|
- `NODE_ENV: "production"` — c'est ce qui empêche `SeedTestUsers` de s'exécuter ;
|
||||||
|
- `BOOTSTRAP_ADMIN_EMAIL` — une adresse que vous **relevez réellement**, c'est
|
||||||
|
par elle que passera le lien de définition du mot de passe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Migrations, puis démarrage
|
||||||
|
|
||||||
|
### 3.1 Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed "s|__IMAGE_TAG__|prod-$SHA|g" k8s/base/07-migration-job.yaml | kubectl apply -f -
|
||||||
|
kubectl -n xpeditis-prod wait --for=condition=complete "job/xpeditis-migrate-prod-$SHA" --timeout=900s
|
||||||
|
kubectl -n xpeditis-prod logs "job/xpeditis-migrate-prod-$SHA"
|
||||||
|
```
|
||||||
|
|
||||||
|
40 migrations doivent s'appliquer. En cas d'échec, les journaux du Job donnent
|
||||||
|
la requête SQL fautive.
|
||||||
|
|
||||||
|
Trois lignes à repérer dans la sortie :
|
||||||
|
|
||||||
|
```
|
||||||
|
SeedTestUsers ignore : NODE_ENV=production. ...
|
||||||
|
[neutralisation] Aucun compte de démonstration présent.
|
||||||
|
[amorçage admin] Administrateur ops@xpeditis.com créé (aucun mot de passe — à définir via « mot de passe oublié »).
|
||||||
|
```
|
||||||
|
|
||||||
|
Si vous lisez `Seeded test users successfully`, **arrêtez-vous** : `NODE_ENV`
|
||||||
|
n'est pas positionné à `production` dans le ConfigMap. Corrigez, puis exécutez
|
||||||
|
`infra/prod/scripts/harden-seed-data.sh` sur db-01 avant de continuer.
|
||||||
|
|
||||||
|
> **Pourquoi un Job.** L'image lance déjà les migrations à chaque démarrage de
|
||||||
|
> pod (`scripts/setup/startup.js`). Avec deux replicas, deux processus migrent
|
||||||
|
> simultanément ; TypeORM ne sérialise pas entre processus et l'un des deux
|
||||||
|
> part en `CrashLoopBackOff`. Le Job (parallélisme 1) applique tout d'abord ;
|
||||||
|
> `startup.js` ne fait ensuite que constater qu'il n'y a rien à migrer.
|
||||||
|
|
||||||
|
### 3.2 Vérifier l'état des comptes avant d'ouvrir quoi que ce soit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_public_ipv4> 'cd /opt/xpeditis/data-node && \
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \
|
||||||
|
"SELECT email, role, is_active FROM users ORDER BY role, email;"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : **une seule ligne**, votre administrateur, `is_active = t`.
|
||||||
|
Aucune adresse `@xpeditis.com` de démonstration ne doit apparaître.
|
||||||
|
|
||||||
|
### 3.3 Démarrer l'application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/base/04-backend.yaml
|
||||||
|
kubectl apply -f k8s/base/05-frontend.yaml
|
||||||
|
kubectl apply -f k8s/base/06-log-exporter.yaml
|
||||||
|
|
||||||
|
kubectl -n xpeditis-prod set image deploy/xpeditis-backend "backend=$REGISTRY/xpeditis-backend:prod-$SHA"
|
||||||
|
kubectl -n xpeditis-prod set image deploy/xpeditis-frontend "frontend=$REGISTRY/xpeditis-frontend:prod-$SHA"
|
||||||
|
kubectl -n xpeditis-prod set image deploy/xpeditis-log-exporter "log-exporter=$REGISTRY/xpeditis-log-exporter:prod-$SHA"
|
||||||
|
|
||||||
|
kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=300s
|
||||||
|
kubectl -n xpeditis-prod rollout status deploy/xpeditis-frontend --timeout=300s
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Exposer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -f k8s/base/09-ingress.yaml
|
||||||
|
kubectl -n xpeditis-prod get ingress
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vérifications
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod get pods -o wide
|
||||||
|
kubectl -n xpeditis-prod logs -l app.kubernetes.io/name=xpeditis-backend --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Le backend doit afficher `PostgreSQL is ready`, `No pending migrations`, puis
|
||||||
|
la bannière de démarrage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# De l'extérieur
|
||||||
|
curl -s https://api.xpeditis.com/api/v1/health | jq
|
||||||
|
curl -sI https://app.xpeditis.com/ | head -1
|
||||||
|
|
||||||
|
# Les comptes de démonstration sont bien morts
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.xpeditis.com/api/v1/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"admin@xpeditis.com","password":"Password123!"}'
|
||||||
|
# Attendu : 401
|
||||||
|
|
||||||
|
# Swagger désactivé
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://api.xpeditis.com/api/docs
|
||||||
|
# Attendu : 404 (ou 401 si vous avez choisi de le protéger)
|
||||||
|
|
||||||
|
bash scripts/smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Symptômes fréquents
|
||||||
|
|
||||||
|
| Symptôme | Cause probable | Vérification |
|
||||||
|
|---|---|---|
|
||||||
|
| `CrashLoopBackOff`, `password authentication failed` | `DATABASE_PASSWORD` ≠ `POSTGRES_PASSWORD` de db-01 | Comparer les deux |
|
||||||
|
| `CrashLoopBackOff`, `no pg_hba.conf entry ... SSL off` | `DATABASE_SSL` absent ou à `false` | ConfigMap |
|
||||||
|
| `CrashLoopBackOff`, `config validation error` | Une variable requise par Joi manque | Les journaux la nomment |
|
||||||
|
| `ImagePullBackOff` | `regcred` absent ou périmé | `kubectl -n xpeditis-prod get secret regcred` |
|
||||||
|
| Connexion OK mais retour sur `/login` | `COOKIE_DOMAIN` sans point initial | ConfigMap |
|
||||||
|
| Erreurs CORS dans la console navigateur | `CORS_ORIGIN` incomplet | ConfigMap |
|
||||||
|
| L'app appelle `api.preprod…` | Image frontend promue au lieu d'être reconstruite | Reconstruire |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Définir le mot de passe de votre administrateur
|
||||||
|
|
||||||
|
Le compte a été créé par la migration, sans mot de passe utilisable. Vous
|
||||||
|
définissez le vôtre par le flux de réinitialisation — le même que celui de vos
|
||||||
|
utilisateurs, ce qui le valide au passage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Demander le lien
|
||||||
|
curl -sS -X POST https://api.xpeditis.com/api/v1/auth/forgot-password \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"ops@xpeditis.com"}'
|
||||||
|
# Réponse toujours 200, même pour une adresse inconnue (anti-énumération).
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou simplement depuis `https://app.xpeditis.com/fr/forgot-password`.
|
||||||
|
|
||||||
|
```
|
||||||
|
2. Ouvrir le lien reçu par courriel (valable 1 heure, à usage unique)
|
||||||
|
3. Définir un mot de passe long et aléatoire, issu de votre gestionnaire
|
||||||
|
4. Se connecter sur https://app.xpeditis.com/fr/login
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contrôles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Un seul ADMIN actif, le vôtre
|
||||||
|
ssh deploy@<db_public_ipv4> 'cd /opt/xpeditis/data-node && \
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \
|
||||||
|
"SELECT email, role, is_active FROM users WHERE role = '"'"'ADMIN'"'"';"'
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si vous avez utilisé la variante `BOOTSTRAP_ADMIN_PASSWORD_HASH` : connectez-vous,
|
||||||
|
> **changez le mot de passe depuis l'interface**, puis retirez la variable du
|
||||||
|
> Secret et réappliquez-le. Un hash qui reste dans un coffre-fort est une cible
|
||||||
|
> d'attaque hors ligne sans aucune contrepartie une fois le compte opérationnel.
|
||||||
|
|
||||||
|
### Si le courriel n'arrive pas
|
||||||
|
|
||||||
|
C'est la chaîne SMTP qui est en cause :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod logs -l app.kubernetes.io/name=xpeditis-backend | grep -i smtp
|
||||||
|
```
|
||||||
|
|
||||||
|
Causes habituelles : clé SMTP Brevo invalide (celle de preprod est compromise
|
||||||
|
et doit avoir été remplacée), domaine expéditeur non vérifié chez Brevo, SPF ou
|
||||||
|
DKIM absents ([08](./08-dns-tls-cloudflare.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Données de référence
|
||||||
|
|
||||||
|
Les migrations installent déjà les ports (`SeedMajorPorts`), les transporteurs
|
||||||
|
(`SeedCarriersAndOrganizations`) et les abonnements gratuits
|
||||||
|
(`SeedFreeSubscriptions`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_public_ipv4> 'cd /opt/xpeditis/data-node && \
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c "
|
||||||
|
SELECT '\''ports'\'' AS t, count(*) FROM ports
|
||||||
|
UNION ALL SELECT '\''carriers'\'', count(*) FROM carriers
|
||||||
|
UNION ALL SELECT '\''organizations'\'', count(*) FROM organizations
|
||||||
|
UNION ALL SELECT '\''users'\'', count(*) FROM users;"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois **organisations de démonstration** subsistent (`Test Freight Forwarder
|
||||||
|
Inc.`, `Demo Shipping Company`, `Sample Shipper Ltd.`). Elles ne sont pas
|
||||||
|
supprimées automatiquement : les comptes désactivés y sont rattachés et une
|
||||||
|
suppression en cascade toucherait `audit_logs`. Renommez-les ou masquez-les
|
||||||
|
depuis l'interface d'administration si elles gênent.
|
||||||
|
|
||||||
|
### Grilles tarifaires
|
||||||
|
|
||||||
|
Les grilles se chargent par l'interface d'administration (import CSV 33
|
||||||
|
colonnes). Chargez au moins une grille export **avant l'ouverture** : sans
|
||||||
|
grille, la recherche de tarifs ne renvoie rien et la plateforme paraît cassée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Images prod-<sha> construites et poussées (frontend reconstruit)
|
||||||
|
[ ] Aucune URL de preprod dans le bundle frontend
|
||||||
|
[ ] ConfigMap vérifié : NODE_ENV, DATABASE_SSL, COOKIE_DOMAIN, CORS_ORIGIN,
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL
|
||||||
|
[ ] Job de migration terminé, 40 migrations appliquées
|
||||||
|
[ ] Journal du Job : « SeedTestUsers ignore : NODE_ENV=production »
|
||||||
|
[ ] Journal du Job : « [amorçage admin] Administrateur ... créé »
|
||||||
|
[ ] La table users ne contient QUE votre administrateur
|
||||||
|
[ ] Connexion admin@xpeditis.com / Password123! → 401
|
||||||
|
[ ] Backend et frontend : 2 pods Running chacun
|
||||||
|
[ ] Ingress créés, https://api.xpeditis.com/api/v1/health → 200
|
||||||
|
[ ] Swagger inaccessible
|
||||||
|
[ ] smoke-test.sh au vert
|
||||||
|
[ ] Lien « mot de passe oublié » reçu, mot de passe défini, connexion réussie
|
||||||
|
[ ] Un seul ADMIN actif en base
|
||||||
|
[ ] Au moins une grille tarifaire chargée
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [10 — CI/CD](./10-cicd-github-actions.md)**
|
||||||
260
docs/mise-en-prod/10-cicd-github-actions.md
Normal file
260
docs/mise-en-prod/10-cicd-github-actions.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
# 10 — CI/CD GitHub Actions
|
||||||
|
|
||||||
|
**Durée : environ 1 h 30.**
|
||||||
|
|
||||||
|
Une fois le premier déploiement manuel réussi, on automatise. Le workflow
|
||||||
|
`.github/workflows/cd-main.yml` a été réécrit pour cette infrastructure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Enchaînement
|
||||||
|
|
||||||
|
```
|
||||||
|
push sur main
|
||||||
|
│
|
||||||
|
├─ Lint + type-check + tests unitaires (backend, frontend)
|
||||||
|
│
|
||||||
|
├─ Vérification : l'image preprod-<sha> existe-t-elle ?
|
||||||
|
│ Si non → BLOCAGE. Ce commit n'est pas passé par la preprod.
|
||||||
|
│
|
||||||
|
├─ Promotion du backend preprod-<sha> → prod-<sha> (aucun rebuild)
|
||||||
|
├─ Reconstruction du frontend avec les URLs de production
|
||||||
|
│ + contrôle : aucune URL de preprod dans le bundle
|
||||||
|
│
|
||||||
|
├─ Déploiement (environnement protégé « production »)
|
||||||
|
│ 1. ouverture du port 22 pour l'IP du runner (firewall Hetzner dédié)
|
||||||
|
│ 2. rsync de infra/prod vers app-01
|
||||||
|
│ 3. ssh « deploy prod-<sha> » → migrations, images, attente
|
||||||
|
│ 4. tests de fumée depuis l'extérieur
|
||||||
|
│ 5. retour arrière automatique en cas d'échec
|
||||||
|
│ 6. fermeture du firewall — TOUJOURS, même sur échec ou annulation
|
||||||
|
│
|
||||||
|
└─ Notification Discord
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Les trois décisions structurantes
|
||||||
|
|
||||||
|
### 2.1 Le backend est promu, le frontend est reconstruit
|
||||||
|
|
||||||
|
Promouvoir garantit que le binaire déployé est **exactement** celui qui a passé
|
||||||
|
la chaîne de preprod, au condensat près. Un rebuild casserait cette garantie.
|
||||||
|
|
||||||
|
Mais `next.config.js` fige `NEXT_PUBLIC_API_URL` au moment du build. L'ancien
|
||||||
|
workflow re-taguait l'image frontend de preprod vers la production : le résultat
|
||||||
|
aurait été une application appelant `api.preprod.xpeditis.com` en production.
|
||||||
|
|
||||||
|
Le frontend est donc **reconstruit** depuis le commit exact déjà vérifié, avec
|
||||||
|
les URLs de production, et une étape de contrôle échoue si la chaîne
|
||||||
|
`api.preprod.xpeditis.com` se retrouve malgré tout dans le bundle.
|
||||||
|
|
||||||
|
### 2.2 Le déploiement passe par SSH, pas par l'API Kubernetes
|
||||||
|
|
||||||
|
L'API k3s (6443) n'est ouverte qu'à vos IP d'administration. Les runners GitHub
|
||||||
|
n'ont pas d'IP fixe, et leurs rangs publiés sont trop vastes et trop mouvants
|
||||||
|
pour une liste blanche.
|
||||||
|
|
||||||
|
Le job ouvre donc le port 22 pour **la seule IP du runner en cours**, via un
|
||||||
|
firewall Hetzner dédié (`xpeditis-prod-fw-cicd`), puis le referme dans une
|
||||||
|
étape `if: always()` — donc y compris si le déploiement échoue, si le job est
|
||||||
|
annulé ou s'il expire.
|
||||||
|
|
||||||
|
### 2.3 Le kubeconfig et la clé SOPS ne sont pas dans GitHub
|
||||||
|
|
||||||
|
- Le **kubeconfig** donne les pleins pouvoirs sur le cluster. Un dépôt compromis
|
||||||
|
ne doit pas les offrir. La CI utilise le kubeconfig local du serveur, à
|
||||||
|
travers une clé SSH restreinte à un script.
|
||||||
|
- La **clé age** déchiffre tous les secrets de production. Les secrets sont
|
||||||
|
appliqués depuis votre poste (`make secrets-apply`), jamais par la CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configurer GitHub
|
||||||
|
|
||||||
|
### 3.1 Environnement protégé
|
||||||
|
|
||||||
|
**Settings → Environments → New environment → `production`**
|
||||||
|
|
||||||
|
| Réglage | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Required reviewers | **vous** (au moins une personne) |
|
||||||
|
| Deployment branches | `main` uniquement |
|
||||||
|
| Wait timer | 0 |
|
||||||
|
|
||||||
|
> Sans `Required reviewers`, tout `push` sur `main` déploie en production sans
|
||||||
|
> validation humaine. Avec, chaque déploiement demande une confirmation
|
||||||
|
> explicite — deux secondes qui ont déjà sauvé beaucoup de vendredis soir.
|
||||||
|
|
||||||
|
### 3.2 Secrets et variables
|
||||||
|
|
||||||
|
La liste complète, avec la façon d'obtenir chaque valeur, est dans
|
||||||
|
[`infra/prod/env/github-secrets.md`](../../infra/prod/env/github-secrets.md).
|
||||||
|
|
||||||
|
Résumé :
|
||||||
|
|
||||||
|
**Secrets** : `REGISTRY_TOKEN`, `HCLOUD_TOKEN_CICD`, `PROD_SSH_HOST`,
|
||||||
|
`PROD_SSH_USER`, `PROD_SSH_KEY`, `PROD_SSH_KNOWN_HOSTS`,
|
||||||
|
`NEXT_PUBLIC_API_URL_PROD`, `NEXT_PUBLIC_APP_URL_PROD`, `DISCORD_WEBHOOK_URL`.
|
||||||
|
|
||||||
|
**Variables** : `PROD_API_URL`, `PROD_APP_URL`, `HCLOUD_CICD_FIREWALL`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Empreinte du serveur, pour PROD_SSH_KNOWN_HOSTS
|
||||||
|
ssh-keyscan -H <app_public_ipv4>
|
||||||
|
```
|
||||||
|
|
||||||
|
> L'empreinte épinglée n'est pas une formalité : sans elle, un détournement DNS
|
||||||
|
> ou BGP pourrait rediriger le déploiement — clé SSH comprise — vers une
|
||||||
|
> machine tierce.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Préparer le serveur
|
||||||
|
|
||||||
|
### 4.1 Répertoire de travail
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> '
|
||||||
|
sudo mkdir -p /opt/xpeditis/infra-prod
|
||||||
|
sudo chown -R deploy:deploy /opt/xpeditis
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Clé de déploiement restreinte
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clé publique de la CI (générée en 01-prerequis.md)
|
||||||
|
cat ~/.ssh/xpeditis_ci.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur app-01, ajoutez-la **avec des restrictions** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> '
|
||||||
|
cat >> ~/.ssh/authorized_keys <<EOF
|
||||||
|
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
|
||||||
|
EOF
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
`restrict` désactive le transfert de ports, d'agent et X11.
|
||||||
|
`command=` force l'exécution du script quelle que soit la commande demandée :
|
||||||
|
**une clé volée ne donne pas un shell**.
|
||||||
|
|
||||||
|
Le wrapper (`infra/prod/scripts/ssh-deploy-wrapper.sh`) n'accepte que quatre
|
||||||
|
choses : le `rsync` de `infra/prod`, `deploy prod-<sha>` (avec validation
|
||||||
|
stricte du tag), `rollback` et `status`. Tout le reste est refusé et journalisé.
|
||||||
|
|
||||||
|
### 4.3 Premier envoi manuel du wrapper
|
||||||
|
|
||||||
|
Le wrapper doit exister avant que la clé restreinte ne puisse servir :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/xpeditis_prod" \
|
||||||
|
infra/prod/ deploy@<app_public_ipv4>:/opt/xpeditis/infra-prod/
|
||||||
|
ssh -i ~/.ssh/xpeditis_prod deploy@<app_public_ipv4> \
|
||||||
|
'chmod +x /opt/xpeditis/infra-prod/scripts/*.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Tester la clé restreinte
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Autorisé
|
||||||
|
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "status"
|
||||||
|
|
||||||
|
# Refusé — c'est le résultat attendu
|
||||||
|
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "cat /opt/xpeditis/data-node/.env.data"
|
||||||
|
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> # pas de shell
|
||||||
|
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "deploy ; rm -rf /"
|
||||||
|
```
|
||||||
|
|
||||||
|
Les refus sont tracés :
|
||||||
|
```bash
|
||||||
|
ssh deploy@<app_public_ipv4> 'sudo journalctl -t xpeditis-ssh-deploy -n 20'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Premier déploiement automatique
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout preprod && git merge main && git push # chaîne preprod
|
||||||
|
# ... attendre que cd-preprod.yml passe au vert ...
|
||||||
|
|
||||||
|
git checkout main && git merge preprod && git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Suivez l'exécution dans l'onglet Actions. Le job `deploy` attendra votre
|
||||||
|
approbation.
|
||||||
|
|
||||||
|
### Vérifier que le firewall s'est bien refermé
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hcloud firewall describe xpeditis-prod-fw-cicd
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendu : **aucune règle**. S'il en reste une, une exécution a été interrompue
|
||||||
|
d'une façon qui a contourné le `if: always()` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo '[]' > /tmp/empty.json
|
||||||
|
hcloud firewall replace-rules xpeditis-prod-fw-cicd --rules-file /tmp/empty.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajoutez ce contrôle à votre routine hebdomadaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Le workflow de retour arrière
|
||||||
|
|
||||||
|
`.github/workflows/rollback.yml` existe déjà. Vérifiez qu'il cible bien la
|
||||||
|
nouvelle infrastructure ; à défaut, le retour arrière manuel reste disponible :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -i ~/.ssh/xpeditis_ci deploy@<app_public_ipv4> "rollback"
|
||||||
|
# ou
|
||||||
|
make -C infra/prod rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Le retour arrière ne défait pas les migrations.** Si la version retirée
|
||||||
|
> contenait une migration destructrice (colonne supprimée, type modifié),
|
||||||
|
> l'ancienne version applicative peut ne plus fonctionner contre le schéma
|
||||||
|
> courant. Voir
|
||||||
|
> [15 § Retour arrière avec migration](./15-exploitation-incidents.md#retour-arriere-avec-migration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ce que la chaîne ne fait pas
|
||||||
|
|
||||||
|
Elle est volontairement conservatrice. Elle **ne fait pas** :
|
||||||
|
|
||||||
|
- de tests end-to-end contre la production (Playwright tourne sur la preprod) ;
|
||||||
|
- de déploiement bleu-vert ou canari — inutile à cette échelle, le
|
||||||
|
`maxUnavailable: 0` suffit à éviter toute coupure ;
|
||||||
|
- de sauvegarde avant déploiement — la sauvegarde quotidienne et l'archivage
|
||||||
|
continu couvrent le besoin. Avant une migration risquée, prenez une
|
||||||
|
sauvegarde manuelle ([12](./12-sauvegardes-restauration.md)) ;
|
||||||
|
- d'analyse de vulnérabilité des images. À ajouter (Trivy) quand vous en aurez
|
||||||
|
le temps ; ce n'est pas bloquant pour le lancement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Environnement GitHub « production » créé, Required reviewers actif
|
||||||
|
[ ] Branches de déploiement limitées à main
|
||||||
|
[ ] 9 secrets renseignés
|
||||||
|
[ ] 3 variables renseignées
|
||||||
|
[ ] /opt/xpeditis/infra-prod créé et appartenant à deploy
|
||||||
|
[ ] Clé CI ajoutée avec restrict + command=
|
||||||
|
[ ] « status » fonctionne, tout le reste est refusé
|
||||||
|
[ ] Refus visibles dans journalctl -t xpeditis-ssh-deploy
|
||||||
|
[ ] Premier déploiement automatique réussi
|
||||||
|
[ ] Firewall CI vide après exécution
|
||||||
|
[ ] Notification Discord reçue
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [11 — Observabilité](./11-observabilite.md)**
|
||||||
273
docs/mise-en-prod/11-observabilite.md
Normal file
273
docs/mise-en-prod/11-observabilite.md
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
# 11 — Observabilité
|
||||||
|
|
||||||
|
**Durée : environ 2 h.**
|
||||||
|
|
||||||
|
Principe : **ce qui n'est pas surveillé n'existe pas.** Une panne détectée par
|
||||||
|
un client est une panne détectée trop tard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Les composants, et pourquoi chacun est là
|
||||||
|
|
||||||
|
| Composant | Rôle | Pourquoi lui |
|
||||||
|
|---|---|---|
|
||||||
|
| **Loki** | Journaux | Indexe des étiquettes, pas le texte intégral : ~10× moins gourmand qu'Elasticsearch, largement suffisant ici. |
|
||||||
|
| **Promtail** | Collecte | Lit `/var/log/pods` (containerd, pas Docker : k3s n'utilise pas Docker). Découpe le JSON pino en champs. |
|
||||||
|
| **Prometheus** | Métriques | 15 jours de rétention. Sans métriques, on ne voit pas une dégradation venir. |
|
||||||
|
| **node-exporter** | Métriques système | Présent pour une alerte avant tout : **disque plein**, la panne la plus fréquente d'un serveur laissé seul. |
|
||||||
|
| **Alertmanager** | Routage | Envoie sur Discord. Règles d'inhibition pour éviter le déluge. |
|
||||||
|
| **Grafana** | Consultation | Une interface pour les deux sources. |
|
||||||
|
| **healthchecks.io** ou **BetterStack** | Externe | **Indispensable** : voir §5. |
|
||||||
|
|
||||||
|
Coût : 0 €. Empreinte : environ 1,5 Go de RAM sur les 16 du serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Déployer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
cd infra/prod
|
||||||
|
make deploy-monitoring
|
||||||
|
```
|
||||||
|
|
||||||
|
Le script vérifie d'abord que les secrets `grafana-admin` et
|
||||||
|
`alertmanager-secrets` existent, puis déploie les six composants et attend leur
|
||||||
|
démarrage.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n monitoring get pods,pvc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Grafana
|
||||||
|
|
||||||
|
`https://grafana.xpeditis.com` — identifiants dans le `Secret`
|
||||||
|
`monitoring/grafana-admin`.
|
||||||
|
|
||||||
|
### Si vous obtenez un 403
|
||||||
|
|
||||||
|
C'est le middleware Traefik `admin-ip-allowlist`. Renseignez votre IP :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$EDITOR k8s/base/08-traefik-middlewares.yaml # section admin-ip-allowlist
|
||||||
|
kubectl apply -f k8s/base/08-traefik-middlewares.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
> Grafana donne accès à l'intégralité de vos journaux applicatifs, jetons
|
||||||
|
> tronqués et identifiants clients compris. Trois couches le protègent : le
|
||||||
|
> firewall Hetzner (IP Cloudflare uniquement), le filtrage par IP Traefik, et
|
||||||
|
> l'authentification Grafana. Si votre IP est dynamique, remplacez la deuxième
|
||||||
|
> par **Cloudflare Access** (Zero Trust, gratuit) plutôt que d'élargir la liste.
|
||||||
|
|
||||||
|
### Vérifier les sources de données
|
||||||
|
|
||||||
|
**Connections → Data sources** : `Loki` et `Prometheus` doivent afficher
|
||||||
|
« Data source is working ».
|
||||||
|
|
||||||
|
### Premières requêtes utiles
|
||||||
|
|
||||||
|
```logql
|
||||||
|
# Toutes les erreurs du backend sur 1 h
|
||||||
|
{namespace="xpeditis-prod", service="xpeditis-backend"} | json | level="error"
|
||||||
|
|
||||||
|
# Échecs d'authentification
|
||||||
|
{namespace="xpeditis-prod"} |= "Unauthorized" or "Invalid credentials"
|
||||||
|
|
||||||
|
# Requêtes PostgreSQL lentes (> 500 ms)
|
||||||
|
{namespace="xpeditis-prod"} |= "duration:" | json | duration > 500
|
||||||
|
|
||||||
|
# Suivre une requête de bout en bout par son identifiant
|
||||||
|
{namespace="xpeditis-prod"} | json | reqId="<uuid>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```promql
|
||||||
|
# Taux d'erreur 5xx
|
||||||
|
sum(rate(traefik_service_requests_total{code=~"5.."}[5m]))
|
||||||
|
/ sum(rate(traefik_service_requests_total[5m]))
|
||||||
|
|
||||||
|
# Latence P95 par service
|
||||||
|
histogram_quantile(0.95, sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service))
|
||||||
|
|
||||||
|
# Connexions PostgreSQL utilisées
|
||||||
|
sum(pg_stat_database_numbackends) / on() pg_settings_max_connections
|
||||||
|
|
||||||
|
# Espace disque restant
|
||||||
|
node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Les alertes
|
||||||
|
|
||||||
|
Définies dans `k8s/monitoring/03-prometheus.yaml`.
|
||||||
|
|
||||||
|
| Alerte | Seuil | Gravité |
|
||||||
|
|---|---|---|
|
||||||
|
| `BackendIndisponible` | Aucune instance saine, 2 min | critique |
|
||||||
|
| `FrontendIndisponible` | Aucune instance saine, 2 min | critique |
|
||||||
|
| `TauxErreur5xxEleve` | > 5 % pendant 5 min | critique |
|
||||||
|
| `LatenceElevee` | P95 > 2 s pendant 10 min | avertissement |
|
||||||
|
| `DisqueBientotPlein` | < 15 % libre, 10 min | critique |
|
||||||
|
| `MemoireNoeudSaturee` | > 90 %, 10 min | avertissement |
|
||||||
|
| `ConteneurRedemarreEnBoucle` | > 3 démarrages en 15 min | critique |
|
||||||
|
| `PostgresInjoignable` | `pg_up == 0`, 2 min | critique |
|
||||||
|
| `ConnexionsPostgresSaturees` | > 80 %, 5 min | avertissement |
|
||||||
|
| `CertificatBientotExpire` | < 15 jours | critique |
|
||||||
|
|
||||||
|
Les critiques sont répétées toutes les heures tant qu'elles ne sont pas
|
||||||
|
résolues ; les avertissements toutes les 12 h.
|
||||||
|
|
||||||
|
### Tester le routage — avant d'en avoir besoin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n monitoring port-forward svc/alertmanager 9093:9093 &
|
||||||
|
|
||||||
|
curl -XPOST http://localhost:9093/api/v2/alerts \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '[{"labels":{"alertname":"TestDeRoutage","severity":"critique"},
|
||||||
|
"annotations":{"summary":"Test de la chaine d alerte"}}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le message doit arriver sur Discord en moins d'une minute. **S'il n'arrive pas,
|
||||||
|
toutes les alertes ci-dessus sont décoratives.** Vérifiez alors :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n monitoring logs deploy/alertmanager --tail=50
|
||||||
|
kubectl -n monitoring get secret alertmanager-secrets -o jsonpath='{.data.discord-webhook}' | base64 -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérifier que Prometheus collecte bien
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n monitoring port-forward svc/prometheus 9090:9090 &
|
||||||
|
open http://localhost:9090/targets
|
||||||
|
```
|
||||||
|
|
||||||
|
Toutes les cibles doivent être `UP`. La plus fragile est `postgres`
|
||||||
|
(`10.10.1.20:9187`) : elle passe par le réseau privé.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Si postgres est DOWN
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose ps postgres-exporter'
|
||||||
|
kubectl -n monitoring exec deploy/prometheus -- wget -qO- http://10.10.1.20:9187/metrics | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Supervision externe — la seule qui compte quand tout brûle
|
||||||
|
|
||||||
|
Prometheus, Alertmanager et Grafana tournent **sur le serveur qu'ils
|
||||||
|
surveillent**. Si app-01 s'éteint, ils s'éteignent avec lui : personne ne vous
|
||||||
|
prévient. C'est le point aveugle structurel de toute supervision interne.
|
||||||
|
|
||||||
|
Il faut donc un observateur **extérieur**.
|
||||||
|
|
||||||
|
### 5.1 Disponibilité
|
||||||
|
|
||||||
|
Sur [healthchecks.io](https://healthchecks.io) (gratuit) ou BetterStack :
|
||||||
|
|
||||||
|
| Contrôle | URL | Fréquence | Alerte après |
|
||||||
|
|---|---|---|---|
|
||||||
|
| API | `https://api.xpeditis.com/api/v1/health` | 1 min | 2 échecs |
|
||||||
|
| Application | `https://app.xpeditis.com/` | 1 min | 2 échecs |
|
||||||
|
| Vitrine | `https://xpeditis.com/` | 5 min | 2 échecs |
|
||||||
|
|
||||||
|
Notification par e-mail **et** SMS (ou notification téléphonique) : un e-mail
|
||||||
|
n'est pas lu à 3 h du matin.
|
||||||
|
|
||||||
|
### 5.2 Battement de cœur des sauvegardes
|
||||||
|
|
||||||
|
C'est le **silence** qui doit alerter. Une alerte « la sauvegarde n'a pas
|
||||||
|
tourné » évaluée par un composant hébergé sur la même machine ne se déclenche
|
||||||
|
pas quand la machine est éteinte — précisément quand elle serait utile.
|
||||||
|
|
||||||
|
1. Créez un *check* de type heartbeat, période 1 jour, marge 6 h.
|
||||||
|
2. Copiez son URL de ping dans `.env.data` de db-01 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> 'sudo $EDITOR /opt/xpeditis/data-node/.env.data'
|
||||||
|
# BACKUP_HEARTBEAT_URL=https://hc-ping.com/<uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Déclenchez une sauvegarde pour vérifier :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
|
||||||
|
```
|
||||||
|
|
||||||
|
Le *check* doit passer au vert sur healthchecks.io.
|
||||||
|
|
||||||
|
### 5.3 Expiration du certificat
|
||||||
|
|
||||||
|
Ajoutez un contrôle TLS externe sur `app.xpeditis.com` (alerte à 14 jours).
|
||||||
|
Il double l'alerte Prometheus, mais fonctionne même quand le cluster est mort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sentry
|
||||||
|
|
||||||
|
Sentry apporte ce que les journaux ne donnent pas : la pile d'appels, les
|
||||||
|
variables locales et le regroupement des erreurs identiques.
|
||||||
|
|
||||||
|
Le backend embarque `@sentry/node`. Ajoutez `SENTRY_DSN` au `Secret` puis
|
||||||
|
redémarrez :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sops k8s/base/03-secrets.sops.yaml # SENTRY_DSN: "https://...@sentry.io/..."
|
||||||
|
make secrets-apply
|
||||||
|
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
> Vérifiez que le DSN est bien lu au démarrage. Le code appelle
|
||||||
|
> `configService.get('SENTRY_DSN')` : si l'initialisation n'est pas câblée dans
|
||||||
|
> `main.ts`, la variable est ignorée en silence. Provoquez une erreur de test et
|
||||||
|
> vérifiez qu'elle apparaît dans Sentry — sinon la variable ne sert à rien.
|
||||||
|
|
||||||
|
Configurez la **purge des données personnelles** côté Sentry (Settings →
|
||||||
|
Security & Privacy → Data Scrubbing) : sans elle, des e-mails et jetons clients
|
||||||
|
partent chez un sous-traitant américain — ce qui a des conséquences RGPD
|
||||||
|
([16](./16-rgpd-conformite.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ce qu'il faut regarder chaque semaine
|
||||||
|
|
||||||
|
Quinze minutes, le lundi :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C infra/prod status
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Erreurs** — Grafana, `{namespace="xpeditis-prod"} | json | level="error"`
|
||||||
|
sur 7 jours. Une erreur récurrente est un défaut, pas du bruit.
|
||||||
|
2. **Latence** — le P95 dérive-t-il ? Une dégradation lente précède la panne.
|
||||||
|
3. **Disque** — `node_filesystem_avail_bytes`. Projetez : dans combien de
|
||||||
|
semaines l'alerte se déclenchera-t-elle ?
|
||||||
|
4. **Sauvegardes** — le *check* heartbeat est-il vert ? La vérification
|
||||||
|
hebdomadaire de restauration est-elle passée ?
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup-verify -n 30 --no-pager'
|
||||||
|
```
|
||||||
|
5. **Firewall CI** — `hcloud firewall describe xpeditis-prod-fw-cicd` : vide ?
|
||||||
|
6. **Certificats** — `kubectl get certificate -A` : `Ready: True` partout ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] 6 composants de supervision Running
|
||||||
|
[ ] Grafana accessible, sources Loki et Prometheus fonctionnelles
|
||||||
|
[ ] Toutes les cibles Prometheus UP, y compris postgres
|
||||||
|
[ ] Alerte de test reçue sur Discord
|
||||||
|
[ ] Contrôles de disponibilité externes configurés (API, app, vitrine)
|
||||||
|
[ ] Battement de cœur des sauvegardes configuré et vert
|
||||||
|
[ ] Contrôle externe d'expiration du certificat
|
||||||
|
[ ] Sentry connecté et erreur de test visible
|
||||||
|
[ ] Purge des données personnelles activée dans Sentry
|
||||||
|
[ ] Routine hebdomadaire notée dans l'agenda
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [12 — Sauvegardes et restauration](./12-sauvegardes-restauration.md)**
|
||||||
315
docs/mise-en-prod/12-sauvegardes-restauration.md
Normal file
315
docs/mise-en-prod/12-sauvegardes-restauration.md
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
# 12 — Sauvegardes et restauration
|
||||||
|
|
||||||
|
**Durée : environ 2 h, dont un test de restauration réel.**
|
||||||
|
|
||||||
|
> **Une sauvegarde qui n'a jamais été restaurée n'est pas une sauvegarde.**
|
||||||
|
> C'est la seule phrase de ce dossier qui mérite d'être affichée au mur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectifs
|
||||||
|
|
||||||
|
| Indicateur | Cible | Ce que cela signifie |
|
||||||
|
|---|---|---|
|
||||||
|
| **RPO** (perte maximale) | **5 minutes** | `archive_timeout = 300` force la clôture d'un segment WAL toutes les 5 min, même sans activité. |
|
||||||
|
| **RTO** (temps de remise en service) | **< 2 h** | Reconstruire db-01 et restaurer via WAL-G. |
|
||||||
|
| Rétention PITR | 7 jours | 7 sauvegardes complètes + les WAL associés. |
|
||||||
|
| Rétention des dumps | 7 j locaux, 30 j distants | Storage Box. |
|
||||||
|
|
||||||
|
Pour une plateforme de réservation, perdre 5 minutes signifie perdre au plus
|
||||||
|
quelques réservations, identifiables dans les journaux Loki et rejouables
|
||||||
|
manuellement. C'est un compromis raisonnable à ce stade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Trois mécanismes, volontairement redondants
|
||||||
|
|
||||||
|
```
|
||||||
|
db-01
|
||||||
|
│
|
||||||
|
┌───────────────────────┼───────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
WAL-G pg_dump -Fc Snapshots
|
||||||
|
continu quotidien Hetzner
|
||||||
|
│ │ │
|
||||||
|
│ chiffré libsodium │ chiffré age │ (image disque)
|
||||||
|
▼ ▼ ▼
|
||||||
|
Object Storage Storage Box Hetzner Cloud
|
||||||
|
xpeditis-prod-pgbackup uXXXXXX/dumps (rétention 7 j)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi trois.**
|
||||||
|
|
||||||
|
| Panne | Ce qui vous sauve |
|
||||||
|
|---|---|
|
||||||
|
| « J'ai supprimé les réservations de mars » | **WAL-G / PITR** — retour à l'instant précédant l'erreur. |
|
||||||
|
| Corruption logique découverte 3 jours après | WAL-G ou dump du jour concerné. |
|
||||||
|
| Compte Object Storage compromis ou supprimé | **Dump sur Storage Box** — autre service, autre identifiant, autre protocole. |
|
||||||
|
| Bug WAL-G, ou montée de version majeure de PostgreSQL | **Dump logique** — restaurable dans n'importe quelle version. |
|
||||||
|
| Perte totale du serveur | WAL-G + snapshot Hetzner. |
|
||||||
|
| Rançongiciel sur db-01 | **Snapshots de la Storage Box** — voir avertissement ci-dessous. |
|
||||||
|
|
||||||
|
> **Le point faible d'un rançongiciel.** db-01 possède les identifiants d'écriture
|
||||||
|
> vers Object Storage **et** vers la Storage Box. Un attaquant ayant obtenu
|
||||||
|
> root peut donc chiffrer ou effacer les deux. La seule protection réelle est
|
||||||
|
> l'**instantané côté fournisseur** : activez les snapshots de la Storage Box
|
||||||
|
> (gratuits, dans son interface) et gardez les sauvegardes Hetzner Cloud
|
||||||
|
> activées. Ils ne sont pas accessibles depuis le serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ce qui tourne automatiquement
|
||||||
|
|
||||||
|
| Unité | Quand | Fait quoi |
|
||||||
|
|---|---|---|
|
||||||
|
| `xpeditis-backup.timer` | tous les jours 02h30 | WAL-G `backup-push`, purge (7 rétentions), `pg_dump` chiffré, envoi Storage Box, purge locale, battement de cœur. |
|
||||||
|
| `xpeditis-backup-verify.timer` | dimanche 04h00 | Restaure le dernier dump dans une base jetable et contrôle sa cohérence. |
|
||||||
|
| `archive_command` | en continu | Chaque segment WAL part sur Object Storage dès sa clôture. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> "systemctl list-timers 'xpeditis-*' --no-pager"
|
||||||
|
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup -n 40 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contrôles de sécurité intégrés
|
||||||
|
|
||||||
|
Le script `pg-backup.sh` refuse de considérer une sauvegarde comme réussie si :
|
||||||
|
- le conteneur PostgreSQL n'est pas démarré ;
|
||||||
|
- `wal-g backup-push` échoue ;
|
||||||
|
- `pg_dump` échoue ;
|
||||||
|
- le dump fait moins de 50 Ko (base vide ou erreur silencieuse) ;
|
||||||
|
- le chiffrement `age` échoue ;
|
||||||
|
- le `rsync` vers la Storage Box échoue.
|
||||||
|
|
||||||
|
Chaque échec part sur Discord **et** le battement de cœur n'est pas envoyé, ce
|
||||||
|
qui déclenche l'alerte externe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Le test de restauration — à faire maintenant
|
||||||
|
|
||||||
|
C'est l'étape que tout le monde saute et qui coûte le plus cher le jour venu.
|
||||||
|
|
||||||
|
### 4.1 Vérification automatique (sans impact)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_public_ipv4>
|
||||||
|
sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Ce que fait le script : déchiffre le dernier dump, crée
|
||||||
|
`xpeditis_restore_check`, restaure avec `--exit-on-error`, compte les tables et
|
||||||
|
les lignes de `users`, `organizations`, `csv_bookings`, `migrations`, échoue si
|
||||||
|
moins de 10 tables ont été restaurées, puis supprime la base jetable.
|
||||||
|
|
||||||
|
Attendu : `SAUVEGARDE VALIDE`.
|
||||||
|
|
||||||
|
### 4.2 Restauration à un instant T — le vrai test
|
||||||
|
|
||||||
|
**Faites-le avant l'ouverture au public**, pendant que l'enjeu est nul.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Marqueur horodaté
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
|
||||||
|
psql -d xpeditis_prod -c "CREATE TABLE test_pitr (t timestamptz DEFAULT now(), note text);
|
||||||
|
INSERT INTO test_pitr(note) VALUES (:'"'"'avant'"'"');
|
||||||
|
SELECT * FROM test_pitr;"'
|
||||||
|
|
||||||
|
# Notez l'horodatage renvoyé.
|
||||||
|
sleep 360 # au-delà de archive_timeout, pour garantir l'archivage du WAL
|
||||||
|
|
||||||
|
# 2. Destruction volontaire
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
|
||||||
|
psql -d xpeditis_prod -c "DROP TABLE test_pitr;"'
|
||||||
|
|
||||||
|
# 3. Arrêter l'application (sinon les écritures pendant la restauration sont perdues)
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0
|
||||||
|
|
||||||
|
# 4. Restaurer à l'instant précédant la suppression
|
||||||
|
ssh deploy@<db_ip>
|
||||||
|
sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "2026-09-01 14:32:00"
|
||||||
|
|
||||||
|
# 5. Suivre le rejeu des WAL
|
||||||
|
cd /opt/xpeditis/data-node && sudo docker compose logs -f postgres
|
||||||
|
# Attendre : "database system is ready to accept connections"
|
||||||
|
|
||||||
|
# 6. Vérifier
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'SELECT * FROM test_pitr;'
|
||||||
|
# La table doit être revenue.
|
||||||
|
|
||||||
|
# 7. Nettoyer et redémarrer
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c 'DROP TABLE test_pitr;'
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chronométrez.** Le temps mesuré ici est votre RTO réel, pas celui du tableau.
|
||||||
|
|
||||||
|
Le script conserve l'ancien répertoire de données dans
|
||||||
|
`/var/lib/xpeditis/pgdata-avant-pitr-<horodatage>`. Supprimez-le **seulement**
|
||||||
|
après avoir validé la restauration — c'est votre filet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Restauration
|
||||||
|
|
||||||
|
### 5.1 Suppression accidentelle de données — PITR
|
||||||
|
|
||||||
|
Le plus fréquent, et le plus stressant.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. NE RIEN FAIRE D'AUTRE. Chaque écriture supplémentaire complique le retour.
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0
|
||||||
|
|
||||||
|
# 2. Déterminer l'instant précis, dans les journaux
|
||||||
|
# Grafana : {namespace="xpeditis-prod"} |= "DELETE" ou "DROP"
|
||||||
|
|
||||||
|
# 3. Restaurer à la seconde qui précède
|
||||||
|
ssh deploy@<db_ip>
|
||||||
|
sudo /opt/xpeditis/data-node/backup/pg-restore.sh pitr "AAAA-MM-JJ HH:MM:SS"
|
||||||
|
|
||||||
|
# 4. Vérifier AVANT de rouvrir
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod \
|
||||||
|
-c 'SELECT count(*) FROM csv_bookings; SELECT max(created_at) FROM csv_bookings;'
|
||||||
|
|
||||||
|
# 5. Rouvrir
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
> **La PITR ramène toute la base à l'instant T.** Les données créées *après*
|
||||||
|
> cet instant sont perdues aussi. Si seules quelques lignes sont concernées,
|
||||||
|
> préférez une restauration logique dans une base séparée (§5.2), puis copiez
|
||||||
|
> uniquement ce qui manque.
|
||||||
|
|
||||||
|
### 5.2 Récupérer quelques lignes sans toucher à la production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip>
|
||||||
|
sudo /opt/xpeditis/data-node/backup/pg-restore.sh logical \
|
||||||
|
/var/lib/xpeditis/dumps/xpeditis-20260901T023000Z.dump.age \
|
||||||
|
xpeditis_recuperation
|
||||||
|
|
||||||
|
# Copier ce qui manque, et rien d'autre
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c "
|
||||||
|
INSERT INTO csv_bookings
|
||||||
|
SELECT * FROM dblink('dbname=xpeditis_recuperation',
|
||||||
|
'SELECT * FROM csv_bookings WHERE id = ''<uuid>''')
|
||||||
|
AS t(...);"
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus simple sans `dblink` : exporter les lignes en CSV depuis la base de
|
||||||
|
récupération, puis les réimporter.
|
||||||
|
|
||||||
|
La production n'est jamais touchée. C'est presque toujours la bonne approche.
|
||||||
|
|
||||||
|
### 5.3 Perte totale de db-01
|
||||||
|
|
||||||
|
RTO visé : moins de 2 h.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Recréer le serveur
|
||||||
|
cd infra/prod/terraform
|
||||||
|
terraform apply -replace=hcloud_server.db
|
||||||
|
# Le volume pgdata a prevent_destroy : il survit et sera rattaché.
|
||||||
|
|
||||||
|
# 2. Réinstaller
|
||||||
|
scp infra/prod/scripts/00-bootstrap-common.sh deploy@<nouvelle_ip>:/tmp/
|
||||||
|
ssh deploy@<nouvelle_ip> 'sudo APP_PRIVATE_IP=10.10.1.10 bash /tmp/00-bootstrap-common.sh data'
|
||||||
|
scp infra/prod/scripts/01-setup-data-node.sh deploy@<nouvelle_ip>:/tmp/
|
||||||
|
ssh deploy@<nouvelle_ip> 'sudo bash /tmp/01-setup-data-node.sh'
|
||||||
|
|
||||||
|
# 3. Redéposer configuration, .env.data (SOPS), clés age et Storage Box
|
||||||
|
# → 04-noeud-donnees.md, sections 3 et 4
|
||||||
|
|
||||||
|
# 4a. Si le volume a survécu : démarrer, c'est tout
|
||||||
|
sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
|
||||||
|
|
||||||
|
# 4b. Si le volume est perdu : restaurer depuis WAL-G
|
||||||
|
sudo docker compose run --rm -u postgres postgres \
|
||||||
|
wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST
|
||||||
|
sudo docker compose run --rm -u postgres postgres bash -c \
|
||||||
|
"touch /var/lib/postgresql/data/pgdata/recovery.signal
|
||||||
|
echo \"restore_command = 'wal-g wal-fetch \\\"%f\\\" \\\"%p\\\"'\" \
|
||||||
|
>> /var/lib/postgresql/data/pgdata/postgresql.auto.conf"
|
||||||
|
sudo docker compose up -d postgres
|
||||||
|
|
||||||
|
# 5. Vérifier puis rouvrir
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si l'IP privée du nouveau serveur diffère de `10.10.1.20`, mettez à jour
|
||||||
|
> `DATABASE_HOST` et `REDIS_HOST` dans le ConfigMap, la NetworkPolicy
|
||||||
|
> `allow-egress-to-data-node`, `pg_hba.conf` et la cible Prometheus.
|
||||||
|
> Terraform réutilise l'IP fixe déclarée : cela ne devrait pas arriver.
|
||||||
|
|
||||||
|
### 5.4 Perte totale de app-01
|
||||||
|
|
||||||
|
Beaucoup plus simple : **aucune donnée n'y réside**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod/terraform && terraform apply -replace=hcloud_server.app
|
||||||
|
# Puis : 03-durcissement-serveurs.md → 05-cluster-k3s.md → 06 → 09
|
||||||
|
# Mettez à jour les enregistrements DNS Cloudflare avec la nouvelle IP.
|
||||||
|
```
|
||||||
|
|
||||||
|
Comptez une heure. C'est le bénéfice direct d'avoir gardé le nœud applicatif
|
||||||
|
entièrement sans état.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sauvegarde manuelle avant opération risquée
|
||||||
|
|
||||||
|
Avant une migration lourde ou une montée de version majeure :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
|
||||||
|
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup -f'
|
||||||
|
```
|
||||||
|
|
||||||
|
Attendez la fin **avant** de lancer l'opération. Notez l'horodatage : c'est
|
||||||
|
votre point de retour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ce qui n'est pas sauvegardé, et pourquoi
|
||||||
|
|
||||||
|
| Élément | Sauvegardé ? | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| PostgreSQL | oui, 3 fois | données métier |
|
||||||
|
| Documents Object Storage | par Hetzner (réplication) | pas de version antérieure : **une suppression est définitive**. Voir ci-dessous. |
|
||||||
|
| Redis | AOF sur disque local | cache + sessions. Une perte déconnecte les utilisateurs, sans plus. |
|
||||||
|
| Journaux Loki / métriques Prometheus | non | 31 j / 15 j de rétention, sans valeur au-delà. |
|
||||||
|
| Configuration k3s | non | entièrement reconstructible depuis `infra/prod/`. |
|
||||||
|
| État Terraform | manuellement | à chiffrer SOPS ou à stocker sur un backend S3. |
|
||||||
|
|
||||||
|
> **Les documents ne sont pas versionnés.** Hetzner Object Storage réplique
|
||||||
|
> mais ne conserve pas d'historique : un connaissement supprimé par erreur — ou
|
||||||
|
> par une faille applicative — est définitivement perdu. Si ces documents ont
|
||||||
|
> une valeur contractuelle (ils en ont), ajoutez une synchronisation
|
||||||
|
> hebdomadaire vers la Storage Box :
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> # Sur db-01, tâche hebdomadaire
|
||||||
|
> rclone sync hetzner:xpeditis-prod-documents \
|
||||||
|
> storagebox:xpeditis-prod/documents --backup-dir storagebox:xpeditis-prod/documents-anciens/$(date +%F)
|
||||||
|
> ```
|
||||||
|
> À mettre en place dans le premier mois d'exploitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Timers xpeditis-backup et xpeditis-backup-verify actifs
|
||||||
|
[ ] Une sauvegarde complète réussie, visible sur Object Storage ET Storage Box
|
||||||
|
[ ] pg-restore.sh verify : SAUVEGARDE VALIDE
|
||||||
|
[ ] TEST PITR RÉEL EFFECTUÉ ET RÉUSSI
|
||||||
|
[ ] RTO réel chronométré et noté
|
||||||
|
[ ] Snapshots de la Storage Box activés
|
||||||
|
[ ] Sauvegardes Hetzner Cloud activées sur les deux serveurs
|
||||||
|
[ ] Battement de cœur externe vert
|
||||||
|
[ ] WALG_LIBSODIUM_KEY et backup-age.key sauvegardées HORS LIGNE
|
||||||
|
[ ] État Terraform sauvegardé
|
||||||
|
[ ] Synchronisation des documents planifiée (dans le premier mois)
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [13 — Sécurité](./13-securite-durcissement.md)**
|
||||||
310
docs/mise-en-prod/13-securite-durcissement.md
Normal file
310
docs/mise-en-prod/13-securite-durcissement.md
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
# 13 — Sécurité
|
||||||
|
|
||||||
|
**Durée : environ 3 h.** C'est le document à relire avant chaque revue et après
|
||||||
|
chaque incident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rotation obligatoire avant la mise en production
|
||||||
|
|
||||||
|
`infra/preprod/docker-stack.preprod.yml` est **versionné dans Git** et contient
|
||||||
|
en clair :
|
||||||
|
|
||||||
|
| Secret | Nature | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| Mot de passe PostgreSQL preprod | interne | Ne jamais réutiliser en prod. Changer aussi en preprod. |
|
||||||
|
| Mot de passe Redis preprod | interne | Idem. |
|
||||||
|
| `JWT_SECRET` preprod | interne | Idem. |
|
||||||
|
| Identifiants MinIO preprod | interne | Idem. |
|
||||||
|
| **Clé SMTP Brevo** | **tiers, active** | **Révoquer immédiatement.** |
|
||||||
|
| Clés Stripe de test | tiers, test | Faire tourner par précaution. |
|
||||||
|
| Mot de passe admin Grafana | interne | Changer. |
|
||||||
|
|
||||||
|
Ils sont dans l'historique Git : les retirer du fichier ne les efface pas.
|
||||||
|
Toute personne ayant eu accès au dépôt — actuel ou ancien collaborateur, fork,
|
||||||
|
sauvegarde, outil d'analyse — les possède.
|
||||||
|
|
||||||
|
### La clé Brevo d'abord
|
||||||
|
|
||||||
|
C'est le seul identifiant qui donne un pouvoir **hors de votre infrastructure** :
|
||||||
|
envoyer des e-mails signés `noreply@xpeditis.com`. Un hameçonnage envoyé depuis
|
||||||
|
votre domaine à vos propres clients est un scénario nettement plus grave qu'un
|
||||||
|
accès à une base de preprod.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Brevo → SMTP & API → révoquer la clé publiée
|
||||||
|
2. Créer une clé PRODUCTION → Secret Kubernetes
|
||||||
|
3. Créer une clé PREPROD distincte → stack preprod
|
||||||
|
4. Vérifier les envois : Brevo → Statistiques → Transactionnel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Puis les autres
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preprod : nouveaux mots de passe partout, puis migration du stack vers SOPS
|
||||||
|
# Production : les secrets sont déjà générés à neuf en 06-secrets-sops.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empêcher la récidive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Détection de secrets dans le dépôt
|
||||||
|
brew install gitleaks
|
||||||
|
gitleaks detect --source . --verbose
|
||||||
|
|
||||||
|
# Crochet de pré-commit
|
||||||
|
cat > .git/hooks/pre-commit <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
if command -v gitleaks >/dev/null; then
|
||||||
|
gitleaks protect --staged --redact || {
|
||||||
|
echo "Un secret a ete detecte dans les fichiers indexes. Commit refuse."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrez également la preprod vers SOPS, sur le modèle de la production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Modèle de menace
|
||||||
|
|
||||||
|
Ce à quoi une plateforme B2B de réservation maritime est réellement exposée :
|
||||||
|
|
||||||
|
| Menace | Vraisemblance | Impact | Ce qui la contre |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Balayage automatisé / force brute SSH | permanente | faible | Clé uniquement, fail2ban, firewall par IP |
|
||||||
|
| Réutilisation d'identifiants clients | élevée | fort | Argon2, limitation de débit à 3 niveaux, jetons courts |
|
||||||
|
| Fuite de secret par le dépôt | **avérée** | **fort** | SOPS, gitleaks, rotation |
|
||||||
|
| Injection SQL | faible | critique | TypeORM paramétré, `class-validator` |
|
||||||
|
| Documents accessibles sans autorisation | moyenne | fort | Bucket privé, URLs pré-signées |
|
||||||
|
| Déni de service | moyenne | moyen | Cloudflare, limitation Traefik |
|
||||||
|
| Rançongiciel sur db-01 | faible | **critique** | Snapshots côté fournisseur (hors de portée du serveur) |
|
||||||
|
| Compromission de la chaîne CI | faible | critique | Environnement protégé, clé SSH restreinte, promotion d'images |
|
||||||
|
| Erreur humaine | **élevée** | fort | PITR, confirmations explicites, `preflight-check.sh` |
|
||||||
|
|
||||||
|
L'erreur humaine et la fuite de secret sont les deux plus probables. C'est là
|
||||||
|
que porte l'essentiel des mesures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Défenses en place, par couche
|
||||||
|
|
||||||
|
### Réseau
|
||||||
|
- Firewall Hetzner : SSH et 6443 sur vos IP ; 80/443 sur les IP Cloudflare.
|
||||||
|
- db-01 : **aucun port applicatif public**. Réseau privé uniquement.
|
||||||
|
- UFW sur les deux nœuds (le firewall Hetzner ne filtre pas le réseau privé).
|
||||||
|
- `NetworkPolicy` k8s : refus par défaut, sortie Internet sans les plages
|
||||||
|
privées — un pod compromis ne peut pas balayer `10.10.0.0/16`.
|
||||||
|
- Firewall CI vide au repos, ouvert deux minutes pour une seule IP.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
- TLS 1.2 minimum, HSTS 1 an, Full (strict) chez Cloudflare.
|
||||||
|
- PostgreSQL : `hostssl` uniquement, une connexion en clair est refusée.
|
||||||
|
- Certificats ECDSA, rotation de clé à chaque renouvellement.
|
||||||
|
|
||||||
|
### Système
|
||||||
|
- SSH par clé, root interdit, 3 tentatives, algorithmes modernes.
|
||||||
|
- fail2ban, bannissement permanent des récidivistes.
|
||||||
|
- Mises à jour de sécurité automatiques, redémarrage nocturne si nécessaire.
|
||||||
|
- auditd sur les fichiers sensibles et les commandes root.
|
||||||
|
- Compte root verrouillé (la console Hetzner ne donne donc pas de session).
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
- `Secret` chiffrés au repos (`--secrets-encryption`).
|
||||||
|
- Journal d'audit de l'API, 30 jours, sans jamais journaliser le contenu des
|
||||||
|
`Secret`.
|
||||||
|
- Pod Security Admission `restricted` : pas de root, pas de privilèges, pas de
|
||||||
|
capability.
|
||||||
|
- Quotas et `LimitRange` : une fuite mémoire n'emporte pas Traefik.
|
||||||
|
- Tableau de bord Traefik désactivé.
|
||||||
|
|
||||||
|
### Application
|
||||||
|
- Argon2 pour les mots de passe.
|
||||||
|
- JWT 15 min / rafraîchissement 7 j, cookies `httpOnly`.
|
||||||
|
- Helmet, CORS en liste blanche stricte (pas de joker, `credentials: true`).
|
||||||
|
- Limitation de débit à trois niveaux indépendants : Cloudflare, Traefik, NestJS.
|
||||||
|
- Swagger désactivé en production.
|
||||||
|
- Journaux expurgés (`authorization`, `x-api-key`, mots de passe).
|
||||||
|
|
||||||
|
### Données
|
||||||
|
- Bucket privé, clés d'accès cloisonnées application / sauvegardes.
|
||||||
|
- Sauvegardes chiffrées côté client (libsodium et age) : un bucket qui fuite ne
|
||||||
|
livre rien.
|
||||||
|
- PITR à 5 minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Revue avant ouverture
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
cd infra/prod
|
||||||
|
DB_PUBLIC_IP=<ip_db> bash scripts/preflight-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contrôles manuels complémentaires
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Rien d'exposé sur db-01 (depuis un AUTRE réseau)
|
||||||
|
nmap -Pn -p- --min-rate 1000 <db_public_ipv4>
|
||||||
|
|
||||||
|
# 2. Rien d'inattendu sur app-01
|
||||||
|
nmap -Pn -p- --min-rate 1000 <app_public_ipv4>
|
||||||
|
|
||||||
|
# 3. Qualité TLS
|
||||||
|
# https://www.ssllabs.com/ssltest/analyze.html?d=app.xpeditis.com → A ou A+
|
||||||
|
|
||||||
|
# 4. En-têtes de sécurité
|
||||||
|
# https://securityheaders.com/?q=https%3A%2F%2Fapp.xpeditis.com → A ou A+
|
||||||
|
|
||||||
|
# 5. Comptes de démonstration morts
|
||||||
|
for c in admin manager user; do
|
||||||
|
echo -n "$c: "
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://api.xpeditis.com/api/v1/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"$c@xpeditis.com\",\"password\":\"Password123!\"}"
|
||||||
|
done
|
||||||
|
# Attendu : 401 trois fois
|
||||||
|
|
||||||
|
# 6. Un seul administrateur
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
|
||||||
|
psql -d xpeditis_prod -c "SELECT email, role, is_active FROM users WHERE role = '"'"'ADMIN'"'"';"'
|
||||||
|
|
||||||
|
# 7. Documents inaccessibles sans autorisation
|
||||||
|
curl -sI https://fsn1.your-objectstorage.com/xpeditis-prod-documents/ | head -1 # 403
|
||||||
|
|
||||||
|
# 8. Aucun secret en clair dans le dépôt
|
||||||
|
gitleaks detect --source . --verbose
|
||||||
|
|
||||||
|
# 9. Une route protégée refuse l'anonyme
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://api.xpeditis.com/api/v1/bookings # 401
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyse des images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install trivy
|
||||||
|
trivy image --severity HIGH,CRITICAL rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest
|
||||||
|
trivy image --severity HIGH,CRITICAL rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Les images `node:20-alpine` remontent régulièrement des CVE de la bibliothèque
|
||||||
|
système. Traitez les CRITICAL avec vecteur réseau ; les autres peuvent attendre
|
||||||
|
la prochaine reconstruction. Reconstruisez les images **au moins une fois par
|
||||||
|
mois** pour absorber les correctifs de base.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Ce que cette configuration ne couvre pas
|
||||||
|
|
||||||
|
Soyez lucide sur les limites :
|
||||||
|
|
||||||
|
| Non couvert | Conséquence | Quand le traiter |
|
||||||
|
|---|---|---|
|
||||||
|
| **Panne d'un nœud** | app-01 en panne = site hors ligne (1 h de reconstruction). | Phase 2 : second nœud + Load Balancer. |
|
||||||
|
| **PostgreSQL non redondé** | Panne de db-01 = coupure jusqu'à restauration (< 2 h). | Phase 2 : standby en réplication. |
|
||||||
|
| **Panne du datacenter fsn1** | Coupure jusqu'à reconstruction ailleurs. | Phase 3 : bi-région. |
|
||||||
|
| **2FA sur les comptes utilisateurs** | Une réutilisation d'identifiants suffit. Le schéma a une colonne `totp_secret` inutilisée. | Dans les 3 mois. |
|
||||||
|
| **Analyse de vulnérabilité en continu** | Trivy est manuel. | Ajouter à la CI quand possible. |
|
||||||
|
| **SOC 2** | Bloquant pour les grands comptes. | Quand un client l'exige (Vanta, ~800 €/mois). |
|
||||||
|
| **Test d'intrusion externe** | Aucun regard extérieur. | Avant les premiers clients importants (3 à 8 k€). |
|
||||||
|
| **Documents non versionnés** | Une suppression est définitive. | [12 § 7](./12-sauvegardes-restauration.md#7--ce-qui-nest-pas-sauvegarde-et-pourquoi) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Réponse à incident
|
||||||
|
|
||||||
|
### 5.1 Suspicion de compromission
|
||||||
|
|
||||||
|
**Ne redémarrez rien.** Un redémarrage détruit les preuves en mémoire et
|
||||||
|
l'attaquant a probablement déjà un moyen de revenir.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. ISOLER — bloquer tout le trafic entrant sauf votre IP
|
||||||
|
cat > /tmp/isolement.json <<JSON
|
||||||
|
[{"direction":"in","protocol":"tcp","port":"22","source_ips":["<votre_ip>/32"]}]
|
||||||
|
JSON
|
||||||
|
hcloud firewall replace-rules xpeditis-prod-fw-app --rules-file /tmp/isolement.json
|
||||||
|
# Le site devient injoignable : c'est l'effet recherché. Pour ne couper que le
|
||||||
|
# trafic public sans perdre l'accès, préférez le mode « Under Attack » de
|
||||||
|
# Cloudflare, qui laisse vos IP passer.
|
||||||
|
|
||||||
|
# 2. CONSTATER
|
||||||
|
ssh deploy@<app_ip> '
|
||||||
|
last -20 # connexions récentes
|
||||||
|
sudo journalctl -t xpeditis-ssh-deploy -n 100
|
||||||
|
sudo ausearch -k rootcmd -ts today | tail -50
|
||||||
|
sudo grep -i "accepted" /var/log/auth.log | tail -30
|
||||||
|
'
|
||||||
|
kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp
|
||||||
|
ssh deploy@<app_ip> 'sudo grep -E "\"verb\":\"(create|delete|patch)\"" /var/log/k3s/audit.log | tail -50'
|
||||||
|
|
||||||
|
# 3. PRÉSERVER
|
||||||
|
ssh deploy@<app_ip> 'sudo tar czf /tmp/preuves.tgz /var/log/auth.log* /var/log/k3s/audit.log* /var/log/audit/'
|
||||||
|
scp deploy@<app_ip>:/tmp/preuves.tgz ./incident-$(date +%F).tgz
|
||||||
|
|
||||||
|
# 4. ÉVALUER : la base a-t-elle été touchée ?
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose logs postgres | grep -iE "connection authorized|FATAL" | tail -50'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Compromission avérée
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Couper l'accès public (firewall, ou Cloudflare en mode « Under Attack »)
|
||||||
|
2. Faire tourner TOUS les secrets (06-secrets-sops.md)
|
||||||
|
3. Révoquer les jetons tiers : Scaleway, Hetzner, Cloudflare, Stripe, Brevo
|
||||||
|
4. Reconstruire app-01 à partir de zéro — ne jamais « nettoyer » un serveur
|
||||||
|
5. Restaurer la base à un instant ANTÉRIEUR à la compromission (PITR)
|
||||||
|
6. Forcer la déconnexion de tous les utilisateurs (rotation du JWT_SECRET)
|
||||||
|
7. Analyser les journaux : quelles données ont été consultées ?
|
||||||
|
8. Notifier la CNIL sous 72 h si des données personnelles sont concernées (16)
|
||||||
|
9. Informer les clients concernés
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Point 4 : reconstruire, pas nettoyer.** Un serveur compromis ne se
|
||||||
|
> désinfecte pas — on ne peut jamais prouver l'absence de porte dérobée.
|
||||||
|
> L'architecture est faite pour cela : app-01 est sans état, sa reconstruction
|
||||||
|
> prend une heure.
|
||||||
|
|
||||||
|
### 5.3 Fuite de données personnelles
|
||||||
|
|
||||||
|
Le RGPD impose une notification à la CNIL **sous 72 heures** à compter de la
|
||||||
|
prise de connaissance. Voir [16 § Violation de données](./16-rgpd-conformite.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Entretien
|
||||||
|
|
||||||
|
| Fréquence | Action |
|
||||||
|
|---|---|
|
||||||
|
| **Hebdomadaire** | Revue Grafana (erreurs, latence, disque). Vérifier que le firewall CI est vide. Vérifier le résultat de la vérification de sauvegarde. |
|
||||||
|
| **Mensuelle** | `trivy` sur les images, reconstruire pour absorber les correctifs de base. Relire les nouveaux comptes ADMIN. `gitleaks detect`. |
|
||||||
|
| **Trimestrielle** | `refresh-cloudflare-ips.sh`. Test de restauration PITR complet. Revue des accès (Hetzner, Cloudflare, GitHub, Stripe). Mise à jour de k3s. |
|
||||||
|
| **Annuelle** | Rotation `JWT_SECRET`, mots de passe base et Redis, clé age. Revue du modèle de menace. Test d'intrusion externe si le chiffre d'affaires le permet. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Clé SMTP Brevo publiée RÉVOQUÉE, nouvelle clé de production en place
|
||||||
|
[ ] Mots de passe base / Redis / JWT / MinIO de preprod changés
|
||||||
|
[ ] Mot de passe admin Grafana changé
|
||||||
|
[ ] gitleaks : aucun secret détecté
|
||||||
|
[ ] Crochet de pré-commit gitleaks installé
|
||||||
|
[ ] preflight-check.sh : aucun point bloquant
|
||||||
|
[ ] nmap depuis l'extérieur : db-01 entièrement filtré
|
||||||
|
[ ] SSL Labs ≥ A
|
||||||
|
[ ] securityheaders.com ≥ A
|
||||||
|
[ ] Comptes de démonstration : 401 sur les trois
|
||||||
|
[ ] Un seul ADMIN actif, le vôtre
|
||||||
|
[ ] Bucket documents : 403 en anonyme
|
||||||
|
[ ] trivy : aucune CRITICAL exploitable à distance
|
||||||
|
[ ] Procédure de réponse à incident lue et comprise
|
||||||
|
[ ] Entretien planifié dans l'agenda
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **Suite : [14 — Runbook de mise en ligne](./14-runbook-go-live.md)**
|
||||||
256
docs/mise-en-prod/14-runbook-go-live.md
Normal file
256
docs/mise-en-prod/14-runbook-go-live.md
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
# 14 — Runbook de mise en ligne
|
||||||
|
|
||||||
|
Le déroulé du dernier jour et de l'ouverture. **Imprimez-le ou gardez-le ouvert
|
||||||
|
à côté de vous** — ce n'est pas le moment de chercher une commande dans un
|
||||||
|
autre fichier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Choisir le moment
|
||||||
|
|
||||||
|
| Bon moment | Mauvais moment |
|
||||||
|
|---|---|
|
||||||
|
| **Mardi ou mercredi matin**, 9 h–10 h | Vendredi, quel que soit l'argument |
|
||||||
|
| Vous êtes disponible les 48 h suivantes | La veille de vos congés |
|
||||||
|
| Aucune migration lourde en attente | Pendant une maintenance Hetzner annoncée |
|
||||||
|
|
||||||
|
Mardi matin laisse trois jours ouvrés pour découvrir et corriger ce qui n'a pas
|
||||||
|
été anticipé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J-1 : répétition générale
|
||||||
|
|
||||||
|
### 1. Contrôle complet (30 min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
cd infra/prod
|
||||||
|
DB_PUBLIC_IP=<ip_db> make preflight
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aucun point bloquant ne doit subsister.** Consignez les avertissements dans un
|
||||||
|
registre des risques daté, avec pour chacun une décision explicite : accepté,
|
||||||
|
ou corrigé avant l'ouverture.
|
||||||
|
|
||||||
|
### 2. Parcours fonctionnels de bout en bout (1 h)
|
||||||
|
|
||||||
|
À faire **dans un navigateur, comme un vrai client**, pas en `curl`.
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Inscription d'un nouveau compte
|
||||||
|
[ ] Réception de l'e-mail de validation (vérifier : boîte principale, pas spam)
|
||||||
|
[ ] Validation, puis connexion
|
||||||
|
[ ] Recherche de tarifs → au moins un résultat s'affiche
|
||||||
|
[ ] Création d'une réservation
|
||||||
|
[ ] Réception de l'e-mail de confirmation
|
||||||
|
[ ] Génération et téléchargement d'un PDF
|
||||||
|
[ ] Lien magique transporteur : réception, ouverture, acceptation
|
||||||
|
[ ] Souscription à un abonnement en mode Live (petit montant, puis remboursement)
|
||||||
|
[ ] Réception du webhook Stripe (Dashboard Stripe → Webhooks → Tentatives)
|
||||||
|
[ ] Notification temps réel visible dans l'interface
|
||||||
|
[ ] Déconnexion, mot de passe oublié, réinitialisation
|
||||||
|
[ ] Espace d'administration accessible avec votre compte ADMIN
|
||||||
|
[ ] Import d'une grille tarifaire CSV
|
||||||
|
[ ] Navigation sur mobile
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Le test Stripe en mode Live est indispensable.** Les clés de test et de
|
||||||
|
> production ont des comportements différents, et le secret de webhook n'est pas
|
||||||
|
> le même. Une souscription à 1 €, immédiatement remboursée, vaut mieux que la
|
||||||
|
> découverte du problème par le premier client.
|
||||||
|
|
||||||
|
> **La notification temps réel** peut ne pas arriver : c'est attendu avec deux
|
||||||
|
> replicas. Voir [15 § Points de vigilance](./15-exploitation-incidents.md#points-de-vigilance-connus).
|
||||||
|
|
||||||
|
### 3. Répétition du retour arrière (20 min)
|
||||||
|
|
||||||
|
Exercez-vous pendant que l'enjeu est nul.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Déployer sciemment une version antérieure
|
||||||
|
kubectl -n xpeditis-prod set image deploy/xpeditis-backend \
|
||||||
|
backend=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_precedent>
|
||||||
|
kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend
|
||||||
|
|
||||||
|
# Puis revenir
|
||||||
|
make rollback
|
||||||
|
bash scripts/smoke-test.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chronométrez.** C'est votre temps de retour réel.
|
||||||
|
|
||||||
|
### 4. Sauvegarde manuelle (10 min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
|
||||||
|
ssh deploy@<db_ip> 'sudo journalctl -u xpeditis-backup -n 30 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
Notez l'horodatage : c'est votre point de retour du jour J.
|
||||||
|
|
||||||
|
### 5. Décision go / no-go
|
||||||
|
|
||||||
|
| Critère | Bloquant ? |
|
||||||
|
|---|---|
|
||||||
|
| `preflight-check.sh` sans point bloquant | **oui** |
|
||||||
|
| Test PITR réussi ([12](./12-sauvegardes-restauration.md)) | **oui** |
|
||||||
|
| Clé SMTP Brevo compromise révoquée | **oui** |
|
||||||
|
| Comptes de démonstration neutralisés (401) | **oui** |
|
||||||
|
| Un seul ADMIN actif | **oui** |
|
||||||
|
| Parcours d'inscription et de réservation fonctionnels | **oui** |
|
||||||
|
| Paiement Stripe testé en Live | **oui** |
|
||||||
|
| Alerte de test reçue sur Discord | **oui** |
|
||||||
|
| Supervision externe active | **oui** |
|
||||||
|
| Retour arrière répété | **oui** |
|
||||||
|
| SSL Labs ≥ A | non — corriger sous 7 j |
|
||||||
|
| Notifications temps réel fiables | non — dégradation connue |
|
||||||
|
| Sentry connecté | non |
|
||||||
|
|
||||||
|
**Un seul « oui » en échec = report.** Reporter d'une semaine coûte infiniment
|
||||||
|
moins cher que d'ouvrir avec un administrateur au mot de passe public.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J0 : ouverture
|
||||||
|
|
||||||
|
### T-30 min
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
cd infra/prod
|
||||||
|
|
||||||
|
make status # tous les pods Running, certificats Ready
|
||||||
|
make smoke # tout au vert
|
||||||
|
|
||||||
|
# Sauvegarde de dernière minute
|
||||||
|
ssh deploy@<db_ip> 'sudo systemctl start xpeditis-backup.service'
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvrez et gardez sous les yeux :
|
||||||
|
- Grafana : `https://grafana.xpeditis.com`
|
||||||
|
- Le canal Discord `#alertes`
|
||||||
|
- Le tableau de bord Stripe
|
||||||
|
- Un terminal avec `make -C infra/prod logs`
|
||||||
|
|
||||||
|
### T-0 : rendre public
|
||||||
|
|
||||||
|
Si vous aviez restreint l'accès pendant la préparation (règle Cloudflare,
|
||||||
|
liste d'IP), retirez-la maintenant.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vérification finale, depuis un réseau extérieur (partage de connexion mobile)
|
||||||
|
curl -sI https://xpeditis.com/ | head -1
|
||||||
|
curl -sI https://app.xpeditis.com/ | head -1
|
||||||
|
curl -s https://api.xpeditis.com/api/v1/health | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Publiez : site vitrine, réseaux sociaux, e-mail aux premiers clients.
|
||||||
|
|
||||||
|
### T+15 min
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make smoke
|
||||||
|
kubectl -n xpeditis-prod get pods
|
||||||
|
kubectl -n xpeditis-prod top pods 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Dans Grafana :
|
||||||
|
- taux d'erreur 5xx — doit rester à 0 ;
|
||||||
|
- latence P95 — sous 1 s ;
|
||||||
|
- mémoire des pods — stable, pas de croissance continue.
|
||||||
|
|
||||||
|
### T+1 h, T+4 h, puis fin de journée
|
||||||
|
|
||||||
|
Les mêmes contrôles. Ce que vous cherchez :
|
||||||
|
|
||||||
|
| Signal | Interprétation |
|
||||||
|
|---|---|
|
||||||
|
| Mémoire du backend en croissance continue | Fuite mémoire → surveiller, redémarrer si besoin |
|
||||||
|
| Erreurs 401 en rafale | Problème de cookie (`COOKIE_DOMAIN`) ou de CORS |
|
||||||
|
| Requêtes PostgreSQL lentes | Index manquant sur une table qui grossit |
|
||||||
|
| Connexions PostgreSQL en hausse | Pool non libéré |
|
||||||
|
| Espace disque en baisse rapide | Journaux ou WAL — vérifier que l'archivage fonctionne |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Si ça tourne mal
|
||||||
|
|
||||||
|
### Le site est cassé pour tout le monde
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C infra/prod rollback
|
||||||
|
make -C infra/prod smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Si le retour arrière ne suffit pas : voir
|
||||||
|
[15 § Le site est hors ligne](./15-exploitation-incidents.md#le-site-est-hors-ligne).
|
||||||
|
|
||||||
|
### Il faut refermer temporairement
|
||||||
|
|
||||||
|
Cloudflare → Security → Settings → **Under Attack Mode**.
|
||||||
|
Les visiteurs passent par une page d'interstitiel ; vos IP restent autorisées.
|
||||||
|
C'est réversible en un clic, contrairement à une modification de firewall.
|
||||||
|
|
||||||
|
### Perte ou corruption de données
|
||||||
|
|
||||||
|
Ne rien faire d'autre, et suivre
|
||||||
|
[12 § Restauration](./12-sauvegardes-restauration.md#5-restauration).
|
||||||
|
Chaque écriture supplémentaire complique la remise en état.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J+1 à J+7
|
||||||
|
|
||||||
|
### Chaque jour
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C infra/prod status
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Aucune alerte non traitée sur Discord
|
||||||
|
[ ] Sauvegarde de la nuit réussie (battement de cœur vert)
|
||||||
|
[ ] Taux d'erreur stable
|
||||||
|
[ ] Aucun pod redémarré sans raison
|
||||||
|
[ ] Aucun paiement en échec inexpliqué côté Stripe
|
||||||
|
```
|
||||||
|
|
||||||
|
### J+7 : bilan
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Vérification hebdomadaire de restauration passée (dimanche 04h00)
|
||||||
|
[ ] Consommation réelle vs dimensionnement — le CPX41 est-il bien calibré ?
|
||||||
|
[ ] Coût réel vs prévision (Xpeditis_Previsions_Couts.xlsx)
|
||||||
|
[ ] Registre des risques relu : les avertissements acceptés sont-ils traités ?
|
||||||
|
[ ] Retours utilisateurs : quelque chose casse-t-il de façon récurrente ?
|
||||||
|
[ ] HSTS : passer de 1 mois à 12 mois + preload si tout est stable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contacts et accès en urgence
|
||||||
|
|
||||||
|
À remplir et à garder **hors du dépôt** (gestionnaire de mots de passe, ou
|
||||||
|
papier dans un tiroir) :
|
||||||
|
|
||||||
|
```
|
||||||
|
Hetzner Cloud console.hetzner.cloud compte : ____________
|
||||||
|
Hetzner Robot robot.hetzner.com (Storage Box)
|
||||||
|
Cloudflare dash.cloudflare.com compte : ____________
|
||||||
|
Registrar ____________
|
||||||
|
Scaleway console.scaleway.com
|
||||||
|
Stripe dashboard.stripe.com
|
||||||
|
Brevo app.brevo.com
|
||||||
|
GitHub github.com/____________
|
||||||
|
|
||||||
|
SSH admin ssh -i ~/.ssh/xpeditis_prod deploy@<app_ip>
|
||||||
|
SSH base ssh -i ~/.ssh/xpeditis_prod deploy@<db_ip>
|
||||||
|
Kubeconfig ~/.kube/xpeditis-prod.yaml
|
||||||
|
Clé age ~/.config/sops/age/keys.txt (+ copie hors ligne : ________)
|
||||||
|
Clé de secours emplacement physique : ____________
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
→ **Suite : [15 — Exploitation et incidents](./15-exploitation-incidents.md)**
|
||||||
405
docs/mise-en-prod/15-exploitation-incidents.md
Normal file
405
docs/mise-en-prod/15-exploitation-incidents.md
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
# 15 — Exploitation et incidents
|
||||||
|
|
||||||
|
Document de référence, à consulter au coup par coup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Points de vigilance connus
|
||||||
|
|
||||||
|
Trois limites identifiées dans le code. Aucune n'empêche l'ouverture, mais
|
||||||
|
toutes doivent être connues avant qu'un client ne les découvre.
|
||||||
|
|
||||||
|
### 1. Notifications temps réel — dégradées avec 2 replicas
|
||||||
|
|
||||||
|
**Constat.** `notifications.gateway.ts` conserve la correspondance
|
||||||
|
`userId → sockets` **en mémoire du processus** et n'utilise pas
|
||||||
|
`@socket.io/redis-adapter`.
|
||||||
|
|
||||||
|
**Conséquence.** Avec deux replicas backend, une notification émise par le
|
||||||
|
replica A n'atteint pas un utilisateur connecté au replica B. En pratique,
|
||||||
|
environ **une notification temps réel sur deux se perd**.
|
||||||
|
|
||||||
|
Les sessions collantes sont configurées (`k8s/base/04-backend.yaml`), ce qui
|
||||||
|
règle la négociation du handshake Socket.IO — mais **pas** la diffusion entre
|
||||||
|
instances.
|
||||||
|
|
||||||
|
**Atténuation actuelle.** Les notifications restent visibles au rechargement
|
||||||
|
(elles sont lues via l'API REST) et les e-mails partent normalement. Seul le
|
||||||
|
« temps réel » est affecté.
|
||||||
|
|
||||||
|
**Solution de contournement immédiate**, si le temps réel est critique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1
|
||||||
|
kubectl -n xpeditis-prod patch hpa xpeditis-backend --type=merge \
|
||||||
|
-p '{"spec":{"minReplicas":1,"maxReplicas":1}}'
|
||||||
|
```
|
||||||
|
Prix à payer : plus de haute disponibilité, et une brève coupure à chaque
|
||||||
|
déploiement.
|
||||||
|
|
||||||
|
**Correctif de fond** (à faire dans le premier mois) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend && npm i @socket.io/redis-adapter ioredis
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/infrastructure/websocket/redis-io.adapter.ts
|
||||||
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import type { ServerOptions } from 'socket.io';
|
||||||
|
|
||||||
|
export class RedisIoAdapter extends IoAdapter {
|
||||||
|
private adapterConstructor: ReturnType<typeof createAdapter>;
|
||||||
|
|
||||||
|
async connectToRedis(): Promise<void> {
|
||||||
|
const opts = {
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: Number(process.env.REDIS_PORT ?? 6379),
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
|
};
|
||||||
|
const pubClient = new Redis(opts);
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
this.adapterConstructor = createAdapter(pubClient, subClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
createIOServer(port: number, options?: ServerOptions): unknown {
|
||||||
|
const server = super.createIOServer(port, options);
|
||||||
|
(server as { adapter: (a: unknown) => void }).adapter(this.adapterConstructor);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// main.ts, avant app.listen()
|
||||||
|
const redisIoAdapter = new RedisIoAdapter(app);
|
||||||
|
await redisIoAdapter.connectToRedis();
|
||||||
|
app.useWebSocketAdapter(redisIoAdapter);
|
||||||
|
```
|
||||||
|
|
||||||
|
La carte `userSockets` en mémoire reste utile localement ; le diffuseur Redis
|
||||||
|
prend en charge la propagation entre instances.
|
||||||
|
|
||||||
|
### 2. La sonde de disponibilité ne teste rien
|
||||||
|
|
||||||
|
`health.controller.ts` renvoie `{status: 'ready', checks: {database: 'ok',
|
||||||
|
redis: 'ok'}}` **en dur**, sans interroger quoi que ce soit.
|
||||||
|
|
||||||
|
**Conséquence.** Un pod incapable de joindre PostgreSQL est déclaré prêt et
|
||||||
|
Traefik lui envoie du trafic. Les utilisateurs prennent des 500 au lieu d'être
|
||||||
|
redirigés vers un pod sain.
|
||||||
|
|
||||||
|
**Correctif** (`@nestjs/terminus`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/backend && npm i @nestjs/terminus
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@Get('ready')
|
||||||
|
@HealthCheck()
|
||||||
|
check() {
|
||||||
|
return this.health.check([
|
||||||
|
() => this.db.pingCheck('database', { timeout: 3000 }),
|
||||||
|
() => this.redis.checkHealth('redis'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
En attendant, la sonde de vivacité (`/health/live`) et les alertes Prometheus
|
||||||
|
couvrent l'essentiel : un pod réellement mort est bien détecté.
|
||||||
|
|
||||||
|
### 3. Retour arrière et migrations
|
||||||
|
|
||||||
|
`rollback` revient à l'image précédente, **pas au schéma précédent**. Voir
|
||||||
|
[§ Retour arrière avec migration](#retour-arriere-avec-migration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Le site est hors ligne
|
||||||
|
|
||||||
|
### Diagnostic, du plus extérieur au plus intérieur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Est-ce Cloudflare ou nous ?
|
||||||
|
curl -sI https://app.xpeditis.com/ | head -3
|
||||||
|
# 521/522 → Cloudflare n'atteint pas l'origine
|
||||||
|
# 523 → problème DNS
|
||||||
|
# 200 → ce n'est pas l'infrastructure : voir l'application
|
||||||
|
|
||||||
|
# 2. Le serveur répond-il ?
|
||||||
|
ping -c3 <app_public_ipv4>
|
||||||
|
ssh deploy@<app_public_ipv4> 'uptime; df -h /; free -m'
|
||||||
|
|
||||||
|
# 3. k3s tourne-t-il ?
|
||||||
|
ssh deploy@<app_public_ipv4> 'sudo systemctl status k3s --no-pager | head -20'
|
||||||
|
|
||||||
|
# 4. Les pods ?
|
||||||
|
kubectl -n xpeditis-prod get pods
|
||||||
|
kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20
|
||||||
|
|
||||||
|
# 5. La base ?
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose ps'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Causes fréquentes
|
||||||
|
|
||||||
|
| Symptôme | Cause | Correctif |
|
||||||
|
|---|---|---|
|
||||||
|
| 521/522, serveur joignable | Les rangs IP Cloudflare ont changé | `bash scripts/refresh-cloudflare-ips.sh --write && terraform apply` |
|
||||||
|
| Pods `Evicted`, `df` proche de 100 % | Disque plein | Voir § Disque plein |
|
||||||
|
| Pods `Pending` | Ressources insuffisantes | `kubectl describe pod` ; réduire les replicas ou agrandir le serveur |
|
||||||
|
| `CrashLoopBackOff` | Erreur applicative | `kubectl logs --previous` |
|
||||||
|
| `ImagePullBackOff` | `regcred` périmé | Recréer le secret ([05 § 4](./05-cluster-k3s.md)) |
|
||||||
|
| Erreurs de connexion base | db-01 en panne | `docker compose up -d` sur db-01 |
|
||||||
|
| k3s ne démarre pas | `--protect-kernel-defaults` | `sysctl --system` puis `systemctl restart k3s` |
|
||||||
|
|
||||||
|
### Disque plein
|
||||||
|
|
||||||
|
La panne la plus fréquente d'un serveur laissé seul.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<ip> '
|
||||||
|
df -h
|
||||||
|
sudo du -sh /var/lib/rancher/k3s/agent/containerd 2>/dev/null
|
||||||
|
sudo du -sh /var/log/* 2>/dev/null | sort -h | tail -10
|
||||||
|
sudo du -sh /var/lib/xpeditis/* 2>/dev/null
|
||||||
|
'
|
||||||
|
|
||||||
|
# Sur app-01 : purger les images inutilisées
|
||||||
|
ssh deploy@<app_ip> 'sudo k3s crictl rmi --prune'
|
||||||
|
|
||||||
|
# Journaux systemd
|
||||||
|
ssh deploy@<ip> 'sudo journalctl --vacuum-size=500M'
|
||||||
|
|
||||||
|
# Sur db-01 : WAL qui s'accumulent = archivage cassé
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && sudo docker compose exec -T -u postgres postgres \
|
||||||
|
psql -c "SELECT * FROM pg_stat_archiver;"'
|
||||||
|
# Si failed_count augmente : WAL-G ne peut plus écrire sur Object Storage.
|
||||||
|
# Vérifier les identifiants dans .env.data et la joignabilité du service.
|
||||||
|
```
|
||||||
|
|
||||||
|
> Des WAL qui s'accumulent finissent par remplir le disque et **arrêter
|
||||||
|
> PostgreSQL**. Un `failed_count` qui monte dans `pg_stat_archiver` est une
|
||||||
|
> urgence, pas un avertissement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retour arrière
|
||||||
|
|
||||||
|
### Cas simple, sans migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C infra/prod rollback
|
||||||
|
make -C infra/prod smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou pour une version précise :
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod rollout history deploy/xpeditis-backend
|
||||||
|
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend --to-revision=3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retour arrière avec migration
|
||||||
|
|
||||||
|
**`rollout undo` ne défait pas les migrations.** Si la version retirée
|
||||||
|
contenait une migration destructrice, l'ancienne version applicative peut ne
|
||||||
|
plus fonctionner contre le schéma courant.
|
||||||
|
|
||||||
|
Trois situations :
|
||||||
|
|
||||||
|
| Migration | Peut-on revenir en arrière simplement ? |
|
||||||
|
|---|---|
|
||||||
|
| Ajout de table ou de colonne nullable | **Oui.** L'ancien code ignore ce qu'il ne connaît pas. |
|
||||||
|
| Ajout de colonne NOT NULL sans défaut | **Non.** L'ancien code fera échouer les insertions. |
|
||||||
|
| Suppression ou renommage de colonne | **Non.** L'ancien code interrogera une colonne absente. |
|
||||||
|
| Changement de type | **Non.** |
|
||||||
|
|
||||||
|
Pour les cas non réversibles :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Arrêter les écritures
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0
|
||||||
|
|
||||||
|
# 2. Annuler la dernière migration
|
||||||
|
kubectl -n xpeditis-prod run migration-revert --rm -it --restart=Never \
|
||||||
|
--image=rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_actuel> \
|
||||||
|
--overrides='{"spec":{"imagePullSecrets":[{"name":"regcred"}],"containers":[{
|
||||||
|
"name":"migration-revert",
|
||||||
|
"image":"rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:prod-<sha_actuel>",
|
||||||
|
"command":["node","./node_modules/typeorm/cli.js","migration:revert","-d",
|
||||||
|
"dist/infrastructure/persistence/typeorm/data-source.js"],
|
||||||
|
"envFrom":[{"configMapRef":{"name":"xpeditis-backend-config"}},
|
||||||
|
{"secretRef":{"name":"xpeditis-backend-secrets"}}]}]}}'
|
||||||
|
|
||||||
|
# 3. Revenir à l'image précédente
|
||||||
|
kubectl -n xpeditis-prod set image deploy/xpeditis-backend backend=...:prod-<sha_precedent>
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2
|
||||||
|
```
|
||||||
|
|
||||||
|
Si `down()` n'est pas implémentée ou échoue, il ne reste que la restauration
|
||||||
|
PITR ([12](./12-sauvegardes-restauration.md)).
|
||||||
|
|
||||||
|
> **Écrivez des migrations réversibles.** Pour supprimer une colonne, procédez
|
||||||
|
> en deux temps : d'abord une version qui cesse de l'utiliser, puis, une fois
|
||||||
|
> celle-ci stabilisée en production, une seconde qui supprime la colonne. Le
|
||||||
|
> retour arrière reste possible à chaque étape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Montée en charge
|
||||||
|
|
||||||
|
### Signaux
|
||||||
|
|
||||||
|
| Signal | Seuil | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| CPU backend soutenu | > 70 % | Le HPA monte à 4. Au-delà, agrandir le serveur. |
|
||||||
|
| Mémoire nœud | > 85 % | Passer en CPX51 (16 vCPU / 32 Go). |
|
||||||
|
| Connexions PostgreSQL | > 80 % | Augmenter `max_connections` ou ajouter PgBouncer. |
|
||||||
|
| Latence P95 | > 1 s | Chercher la cause avant d'ajouter des ressources. |
|
||||||
|
| Disque db-01 | > 70 % | Agrandir le volume (à chaud). |
|
||||||
|
|
||||||
|
### Agrandir un serveur
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=1 # app-01 seulement
|
||||||
|
ssh deploy@<ip> 'sudo shutdown -h now'
|
||||||
|
# Console Hetzner → Rescale → CPX51 → Power on
|
||||||
|
# Ou : $EDITOR terraform.tfvars puis terraform apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Quelques minutes de coupure. Le disque ne peut qu'être **agrandi**, jamais
|
||||||
|
réduit : la manœuvre est irréversible.
|
||||||
|
|
||||||
|
### Agrandir le volume PostgreSQL
|
||||||
|
|
||||||
|
Sans coupure :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod/terraform
|
||||||
|
$EDITOR terraform.tfvars # db_volume_size = 100
|
||||||
|
terraform apply
|
||||||
|
|
||||||
|
ssh deploy@<db_ip> '
|
||||||
|
sudo resize2fs /dev/disk/by-id/scsi-0HC_Volume_*
|
||||||
|
df -h /var/lib/xpeditis/pgdata
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passer à deux nœuds (phase 2)
|
||||||
|
|
||||||
|
Quand un seul nœud ne suffit plus, ou que la coupure lors d'une panne devient
|
||||||
|
inacceptable :
|
||||||
|
|
||||||
|
1. Ajouter un `hcloud_server.app2` dans Terraform (mêmes firewalls, même réseau).
|
||||||
|
2. L'installer en agent k3s :
|
||||||
|
```bash
|
||||||
|
curl -sfL https://get.k3s.io | K3S_URL=https://10.10.1.10:6443 \
|
||||||
|
K3S_TOKEN=$(ssh deploy@<app1> 'sudo cat /var/lib/rancher/k3s/server/node-token') sh -
|
||||||
|
```
|
||||||
|
3. Ajouter un Load Balancer Hetzner LB11 (6 €/mois) devant les deux nœuds.
|
||||||
|
4. Les `topologySpreadConstraints` déjà présents répartiront automatiquement les
|
||||||
|
pods — ils sont en `ScheduleAnyway`, ils deviennent effectifs sans
|
||||||
|
modification.
|
||||||
|
5. Ajouter un standby PostgreSQL en réplication sur un troisième serveur.
|
||||||
|
|
||||||
|
Budget correspondant : ligne « 1 000 utilisateurs » du fichier de prévisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mises à jour
|
||||||
|
|
||||||
|
### Application
|
||||||
|
|
||||||
|
Chaîne normale : `preprod` → validation → `main` → déploiement automatique.
|
||||||
|
|
||||||
|
### Système (automatique)
|
||||||
|
|
||||||
|
`unattended-upgrades` applique les correctifs de sécurité chaque nuit et
|
||||||
|
redémarre à 04h30 si le noyau l'exige.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh deploy@<ip> 'sudo journalctl -u unattended-upgrades -n 30 --no-pager'
|
||||||
|
```
|
||||||
|
|
||||||
|
> Le redémarrage automatique d'app-01 provoque **quelques minutes de coupure** :
|
||||||
|
> k3s redémarre, puis les pods. C'est un choix assumé — un noyau non corrigé est
|
||||||
|
> un risque plus grand que quelques minutes d'indisponibilité nocturne. Avec
|
||||||
|
> deux nœuds (phase 2), décalez les fenêtres pour supprimer la coupure.
|
||||||
|
|
||||||
|
### k3s (trimestriel)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sauvegarde de l'état du cluster
|
||||||
|
ssh deploy@<app_ip> 'sudo k3s etcd-snapshot save --name avant-maj'
|
||||||
|
|
||||||
|
# Mise à jour
|
||||||
|
ssh deploy@<app_ip> 'curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=v1.32.x+k3s1 sh -'
|
||||||
|
kubectl get nodes
|
||||||
|
make -C infra/prod smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Restez sur une version mineure de retard par rapport à la dernière : les
|
||||||
|
correctifs de régression arrivent vite.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
Les correctifs (15.x → 15.y) sont sans risque :
|
||||||
|
```bash
|
||||||
|
ssh deploy@<db_ip> 'cd /opt/xpeditis/data-node && \
|
||||||
|
sudo docker compose --env-file .env.data pull postgres && \
|
||||||
|
sudo docker compose --env-file .env.data up -d postgres'
|
||||||
|
```
|
||||||
|
|
||||||
|
Une montée de version **majeure** (15 → 16) impose un `pg_dump` / `pg_restore`
|
||||||
|
et une fenêtre de maintenance planifiée. Ne l'improvisez pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commandes de tous les jours
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod
|
||||||
|
|
||||||
|
make status # vue d'ensemble
|
||||||
|
make logs # journaux backend en direct
|
||||||
|
make events # derniers événements Kubernetes
|
||||||
|
make smoke # l'extérieur voit-il un service sain ?
|
||||||
|
make restart # redémarrer les pods (après changement de secret)
|
||||||
|
make rollback # revenir à la version précédente
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
ssh deploy@<db_ip>
|
||||||
|
cd /opt/xpeditis/data-node
|
||||||
|
sudo docker compose exec -u postgres postgres psql -d xpeditis_prod
|
||||||
|
|
||||||
|
# Requêtes en cours
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c "
|
||||||
|
SELECT pid, now()-query_start AS duree, state, left(query, 80)
|
||||||
|
FROM pg_stat_activity WHERE state <> 'idle' ORDER BY duree DESC;"
|
||||||
|
|
||||||
|
# Tuer une requête bloquante
|
||||||
|
sudo docker compose exec -T -u postgres postgres psql -d xpeditis_prod -c \
|
||||||
|
"SELECT pg_cancel_backend(<pid>);" # doux
|
||||||
|
# puis, si nécessaire : pg_terminate_backend(<pid>)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Journal des incidents
|
||||||
|
|
||||||
|
Tenez-le. Après trois mois, il vous dira où porter vos efforts bien mieux que
|
||||||
|
n'importe quelle intuition.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## AAAA-MM-JJ — Titre court
|
||||||
|
|
||||||
|
**Détecté** : par qui / quoi, à quelle heure
|
||||||
|
**Durée** : de HH:MM à HH:MM
|
||||||
|
**Impact** : combien d'utilisateurs, quelles fonctions
|
||||||
|
**Cause** : ce qui s'est réellement passé
|
||||||
|
**Correctif** : ce qui a été fait
|
||||||
|
**Prévention** : ce qui empêche la récidive — et si rien, pourquoi
|
||||||
|
```
|
||||||
241
docs/mise-en-prod/16-rgpd-conformite.md
Normal file
241
docs/mise-en-prod/16-rgpd-conformite.md
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
# 16 — RGPD et conformité
|
||||||
|
|
||||||
|
Xpeditis traite des données personnelles de professionnels : noms, adresses
|
||||||
|
e-mail, numéros de téléphone, adresses, SIRET, documents de transport nominatifs.
|
||||||
|
Le RGPD s'applique intégralement.
|
||||||
|
|
||||||
|
> Ce document décrit ce que l'infrastructure met en place et ce qu'il reste à
|
||||||
|
> faire. Il ne remplace pas un conseil juridique. Faites relire vos mentions
|
||||||
|
> légales, votre politique de confidentialité et vos contrats de sous-traitance
|
||||||
|
> par un professionnel avant de démarcher des grands comptes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ce que l'infrastructure apporte déjà
|
||||||
|
|
||||||
|
| Exigence | Mise en œuvre |
|
||||||
|
|---|---|
|
||||||
|
| Hébergement dans l'UE | Hetzner, Falkenstein (Allemagne). Terraform refuse toute région hors UE. |
|
||||||
|
| Chiffrement en transit | TLS 1.2+ partout, y compris entre l'application et PostgreSQL. |
|
||||||
|
| Chiffrement au repos | Sauvegardes chiffrées côté client (libsodium, age). `Secret` k8s chiffrés dans l'état de k3s. |
|
||||||
|
| Contrôle d'accès | RBAC applicatif, Argon2, JWT courts, cookies `httpOnly`. |
|
||||||
|
| Traçabilité | `audit_logs` applicatif, journal d'audit Kubernetes, auditd système. |
|
||||||
|
| Minimisation dans les journaux | pino expurge `authorization`, `x-api-key` et les mots de passe. Traefik ne conserve que `User-Agent` et `Cf-Connecting-Ip`. |
|
||||||
|
| Durée de conservation des journaux | Loki 31 jours, Prometheus 15 jours, audit k8s 30 jours. |
|
||||||
|
| Sécurité des sauvegardes | Chiffrées, cloisonnées, testées. |
|
||||||
|
| Résilience | PITR 5 min, RTO < 2 h — l'article 32 exige de pouvoir « rétablir la disponibilité ». |
|
||||||
|
|
||||||
|
> **Le chiffrement du disque n'est pas activé.** Les volumes Hetzner ne sont pas
|
||||||
|
> chiffrés au repos par défaut. En pratique, la protection repose sur la sécurité
|
||||||
|
> physique du datacenter (certifié ISO 27001) et sur la destruction sécurisée des
|
||||||
|
> supports. Si un client l'exige contractuellement, il faut mettre en place LUKS
|
||||||
|
> sur le volume PostgreSQL — au prix d'une clé à saisir ou à stocker à chaque
|
||||||
|
> démarrage, ce qui déplace le problème plus qu'il ne le résout sur un serveur
|
||||||
|
> distant. À arbitrer, pas à improviser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Registre des sous-traitants
|
||||||
|
|
||||||
|
À tenir à jour. C'est la première chose que demande un client grand compte, et
|
||||||
|
c'est obligatoire au titre de l'article 30.
|
||||||
|
|
||||||
|
| Sous-traitant | Rôle | Données | Localisation | Transfert hors UE | DPA |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **Hetzner Online GmbH** | Hébergement, stockage, sauvegardes | Toutes | Allemagne | non | [DPA Hetzner](https://www.hetzner.com/AV/DPA_en.pdf) à signer |
|
||||||
|
| **Cloudflare Inc.** | DNS, WAF, CDN | IP, en-têtes, métadonnées de requête | Réseau mondial | **oui** (États-Unis) | DPA + CCT, incluses aux CGU |
|
||||||
|
| **Stripe Inc.** | Paiements | Nom, e-mail, données de facturation | UE + États-Unis | **oui** | DPA Stripe |
|
||||||
|
| **Brevo (Sendinblue SAS)** | E-mails transactionnels | Nom, e-mail, contenu des messages | France | non | DPA Brevo |
|
||||||
|
| **Functional Software Inc. (Sentry)** | Suivi des erreurs | Traces, potentiellement des identifiants utilisateur | États-Unis | **oui** | DPA + purge des données |
|
||||||
|
| **Pappers (SAS)** | Vérification SIRET | SIRET, raison sociale | France | non | DPA |
|
||||||
|
| **Scaleway SAS** | Registre d'images | Aucune donnée personnelle | France | non | — |
|
||||||
|
| **GitHub Inc.** | Code source, CI | Aucune donnée client | États-Unis | oui | — |
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Signer le DPA Hetzner (formulaire dans la console)
|
||||||
|
[ ] Récupérer et archiver les DPA Cloudflare, Stripe, Brevo, Sentry, Pappers
|
||||||
|
[ ] Activer la purge des données personnelles dans Sentry
|
||||||
|
Settings → Security & Privacy → Data Scrubbing → activer
|
||||||
|
+ champs supplémentaires : email, phone, siret, token
|
||||||
|
[ ] Documenter ce registre dans un document versionné
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Sentry mérite une attention particulière.** Une trace d'exception peut
|
||||||
|
> contenir l'e-mail, l'identifiant et le corps de requête d'un utilisateur, et
|
||||||
|
> partir chez un sous-traitant américain. Activez la purge **avant** de connecter
|
||||||
|
> Sentry à la production, pas après.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Durées de conservation
|
||||||
|
|
||||||
|
À décider, puis à appliquer techniquement. Proposition de départ :
|
||||||
|
|
||||||
|
| Donnée | Durée proposée | Justification |
|
||||||
|
|---|---|---|
|
||||||
|
| Compte utilisateur actif | Durée de la relation contractuelle | Exécution du contrat |
|
||||||
|
| Compte inactif | 3 ans après la dernière connexion | Recommandation CNIL en prospection B2B |
|
||||||
|
| Réservations et documents de transport | **10 ans** | Obligation comptable et commerciale (art. L123-22 code de commerce) |
|
||||||
|
| Factures | 10 ans | Idem |
|
||||||
|
| `audit_logs` | 1 an | Sécurité, art. 32 |
|
||||||
|
| Journaux techniques (Loki) | 31 jours | Déjà appliqué |
|
||||||
|
| Sauvegardes | 7 j PITR, 30 j dumps | Déjà appliqué |
|
||||||
|
| Cookies analytiques | 13 mois | Recommandation CNIL |
|
||||||
|
|
||||||
|
> **La conservation légale prime sur le droit à l'effacement.** Un client peut
|
||||||
|
> demander la suppression de son compte : les réservations qui portent une
|
||||||
|
> obligation comptable doivent être conservées, mais peuvent être
|
||||||
|
> **pseudonymisées** (retirer nom, e-mail, téléphone en gardant les montants et
|
||||||
|
> références). C'est ce que doit faire la fonction d'effacement du module GDPR.
|
||||||
|
|
||||||
|
### À vérifier dans le code
|
||||||
|
|
||||||
|
Le backend expose un module `gdpr`. Vérifiez qu'il fait bien ce qu'il annonce :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Que fait réellement l'export ? Que fait la suppression ?
|
||||||
|
grep -rn "class.*Gdpr\|anonymi\|pseudonym" apps/backend/src/application/gdpr/
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] L'export de données couvre : compte, organisation, réservations, documents
|
||||||
|
[ ] La suppression pseudonymise au lieu d'effacer les données à conservation légale
|
||||||
|
[ ] Une purge automatique des comptes inactifs > 3 ans existe (ou est planifiée)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Documents obligatoires
|
||||||
|
|
||||||
|
| Document | Où | Statut |
|
||||||
|
|---|---|---|
|
||||||
|
| Politique de confidentialité | `/privacy` (route publique existante) | à rédiger |
|
||||||
|
| Mentions légales | `/terms` | à rédiger |
|
||||||
|
| Politique de cookies + bandeau de consentement | `/cookies` | à rédiger |
|
||||||
|
| CGU / CGV | `/terms` | à rédiger |
|
||||||
|
| Registre des traitements (art. 30) | interne | à créer |
|
||||||
|
| Registre des sous-traitants | interne | §2 ci-dessus |
|
||||||
|
| Procédure de violation de données | interne | §6 ci-dessous |
|
||||||
|
|
||||||
|
Les routes `/privacy`, `/terms` et `/cookies` sont déjà publiques dans
|
||||||
|
`middleware.ts` : il ne manque que le contenu.
|
||||||
|
|
||||||
|
### Bandeau de cookies
|
||||||
|
|
||||||
|
Nécessaire **uniquement** si vous déposez des cookies non essentiels (mesure
|
||||||
|
d'audience, publicité). Les cookies d'authentification `httpOnly` sont
|
||||||
|
strictement nécessaires et n'exigent pas de consentement.
|
||||||
|
|
||||||
|
Si vous ajoutez Google Analytics (`NEXT_PUBLIC_GA_ID` est prévu dans le
|
||||||
|
Dockerfile frontend), un bandeau de consentement **préalable** devient
|
||||||
|
obligatoire — le script ne doit pas se charger avant l'acceptation.
|
||||||
|
|
||||||
|
**Alternative plus simple** : Plausible ou Matomo auto-hébergé, sans cookie ni
|
||||||
|
consentement requis. Moins de code, moins de risque juridique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Droits des personnes
|
||||||
|
|
||||||
|
Vous devez répondre sous **un mois**.
|
||||||
|
|
||||||
|
| Droit | Mise en œuvre |
|
||||||
|
|---|---|
|
||||||
|
| Accès / portabilité | Module GDPR — export JSON ou CSV |
|
||||||
|
| Rectification | Interface de profil |
|
||||||
|
| Effacement | Module GDPR — pseudonymisation, cf. §3 |
|
||||||
|
| Limitation | Désactivation du compte (`is_active = false`) |
|
||||||
|
| Opposition | Désinscription des communications |
|
||||||
|
|
||||||
|
Publiez une adresse de contact dédiée (`privacy@xpeditis.com` ou
|
||||||
|
`dpo@xpeditis.com`) dans la politique de confidentialité, et **relevez-la**.
|
||||||
|
|
||||||
|
### DPO
|
||||||
|
|
||||||
|
Non obligatoire pour une PME dont le traitement de données personnelles n'est
|
||||||
|
ni massif ni sensible. Désignez néanmoins un **référent RGPD** nommément —
|
||||||
|
c'est ce que demandent les clients grands comptes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Violation de données
|
||||||
|
|
||||||
|
**Notification à la CNIL sous 72 heures** à compter de la prise de connaissance
|
||||||
|
(art. 33). Si le risque pour les personnes est élevé, il faut **aussi** les
|
||||||
|
informer directement (art. 34).
|
||||||
|
|
||||||
|
### Procédure
|
||||||
|
|
||||||
|
```
|
||||||
|
1. CONSTATER Suivre 13 § Réponse à incident. Préserver les preuves.
|
||||||
|
2. QUALIFIER Quelles données ? Combien de personnes ? Quel risque réel ?
|
||||||
|
3. CONTENIR Isoler, faire tourner les secrets, restaurer.
|
||||||
|
4. NOTIFIER CNIL sous 72 h : notifications.cnil.fr
|
||||||
|
Même incomplète, une notification dans les délais vaut mieux
|
||||||
|
qu'une notification complète hors délai.
|
||||||
|
5. INFORMER Les personnes concernées si le risque est élevé.
|
||||||
|
6. DOCUMENTER Journal des violations : obligatoire, même sans notification.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ce dont vous aurez besoin
|
||||||
|
|
||||||
|
- Quelles données, pour combien de personnes ?
|
||||||
|
- Depuis quand, jusqu'à quand ?
|
||||||
|
- Comment cela a-t-il été découvert ?
|
||||||
|
- Quelles mesures ont été prises ?
|
||||||
|
- Quelles conséquences probables pour les personnes ?
|
||||||
|
|
||||||
|
Les sources : `audit_logs`, Loki (31 j), journal d'audit k8s (30 j), auditd,
|
||||||
|
journaux PostgreSQL. **Ces rétentions déterminent votre capacité à répondre.**
|
||||||
|
Une intrusion découverte 45 jours après coup ne sera pas reconstituable — c'est
|
||||||
|
un argument pour porter la rétention Loki à 90 jours dès que le volume le permet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ce qu'exigeront les grands comptes
|
||||||
|
|
||||||
|
Au-delà du RGPD, attendez-vous à :
|
||||||
|
|
||||||
|
| Demande | Statut | Coût |
|
||||||
|
|---|---|---|
|
||||||
|
| Questionnaire sécurité (50-200 questions) | ce dossier y répond en grande partie | temps |
|
||||||
|
| DPA signé de votre côté | à préparer | juridique |
|
||||||
|
| Preuve de sauvegardes testées | [12](./12-sauvegardes-restauration.md) | fait |
|
||||||
|
| Engagement de disponibilité (SLA) | pas d'engagement contractuel aujourd'hui | à arbitrer |
|
||||||
|
| Test d'intrusion récent | non fait | 3 à 8 k€ |
|
||||||
|
| SOC 2 Type II | non | ~800 €/mois (Vanta) + 15-25 k€ d'audit |
|
||||||
|
| ISO 27001 | non | 20-40 k€ |
|
||||||
|
| Assurance responsabilité civile professionnelle / cyber | **prévue au budget** (150 €/mois) | à souscrire |
|
||||||
|
|
||||||
|
**Souscrivez la RC Pro / cyber avant l'ouverture.** Elle figure dans le fichier
|
||||||
|
de prévisions et couvre précisément ce que la technique ne peut pas couvrir :
|
||||||
|
les conséquences financières d'un incident.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Contrôle
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] Hébergement UE confirmé (fsn1)
|
||||||
|
[ ] DPA Hetzner signé
|
||||||
|
[ ] DPA Cloudflare, Stripe, Brevo, Sentry, Pappers archivés
|
||||||
|
[ ] Purge des données personnelles activée dans Sentry
|
||||||
|
[ ] Registre des sous-traitants rédigé
|
||||||
|
[ ] Registre des traitements (art. 30) rédigé
|
||||||
|
[ ] Durées de conservation décidées et documentées
|
||||||
|
[ ] Module GDPR vérifié : export complet, suppression pseudonymisante
|
||||||
|
[ ] Politique de confidentialité publiée sur /privacy
|
||||||
|
[ ] Mentions légales et CGU publiées sur /terms
|
||||||
|
[ ] Politique de cookies publiée sur /cookies
|
||||||
|
[ ] Adresse privacy@ ou dpo@ publiée et relevée
|
||||||
|
[ ] Référent RGPD désigné nommément
|
||||||
|
[ ] Procédure de violation de données rédigée et accessible hors ligne
|
||||||
|
[ ] Assurance RC Pro / cyber souscrite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fin de la documentation de mise en production.**
|
||||||
|
Retour à l'[index](./README.md).
|
||||||
174
docs/mise-en-prod/README.md
Normal file
174
docs/mise-en-prod/README.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Mise en production — Xpeditis sur Hetzner
|
||||||
|
|
||||||
|
Procédure complète, pas à pas, pour ouvrir Xpeditis au public de manière sûre.
|
||||||
|
|
||||||
|
**Option retenue** : Hetzner auto-hébergé (feuille « Hetzner (auto-hébergé) » du
|
||||||
|
fichier `Xpeditis_Previsions_Couts.xlsx`), k3s sur 2 serveurs, ≈ 66 € HT/mois
|
||||||
|
d'infrastructure.
|
||||||
|
|
||||||
|
Tous les fichiers évoqués ici vivent dans [`infra/prod/`](../../infra/prod/README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lisez ceci en premier
|
||||||
|
|
||||||
|
L'analyse du dépôt a mis au jour **quatre problèmes qui auraient compromis ou
|
||||||
|
cassé la production**. Ils sont traités dans les fichiers livrés, mais deux
|
||||||
|
exigent une action de votre part.
|
||||||
|
|
||||||
|
### 0. Une migration créait un ADMIN au mot de passe public — corrigé
|
||||||
|
|
||||||
|
`1730000000007-SeedTestUsers` insérait `admin@xpeditis.com` (rôle **ADMIN**),
|
||||||
|
`manager@` et `user@`, tous avec le mot de passe `Password123!` — écrit en clair
|
||||||
|
dans le dépôt. Sur une base de production neuve, appliquer les migrations créait
|
||||||
|
donc un administrateur dont les identifiants sont publics. C'était le point le
|
||||||
|
plus grave du parcours.
|
||||||
|
|
||||||
|
Trois mécanismes, tous automatiques :
|
||||||
|
|
||||||
|
| Migration | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `1730000000007-SeedTestUsers` (modifiée) | **Ne s'exécute plus** si `NODE_ENV=production`. Les comptes ne sont jamais créés. Dev et preprod gardent les leurs. |
|
||||||
|
| `1756000000000-NeutralizeSeedAccountsInProduction` | Filet de sécurité pour toute base où ils existeraient déjà : renommage, hash inauthentifiable, désactivation. Échoue si le nettoyage est incomplet. |
|
||||||
|
| `1756000000001-BootstrapAdminFromEnv` | Crée **votre** administrateur depuis `BOOTSTRAP_ADMIN_EMAIL`, **sans aucun mot de passe stocké** : vous définissez le vôtre via « mot de passe oublié ». |
|
||||||
|
|
||||||
|
Résultat : aucun secret n'existe nulle part — ni dans Git, ni dans le Secret
|
||||||
|
Kubernetes, ni dans l'historique du shell. `preflight-check.sh` vérifie ensuite
|
||||||
|
en conditions réelles que `Password123!` est bien refusé sur les trois adresses.
|
||||||
|
|
||||||
|
→ **[09 — Déploiement applicatif](./09-deploiement-application.md#le-premier-administrateur)**
|
||||||
|
|
||||||
|
### 1. Les secrets de preprod sont dans l'historique Git — action requise
|
||||||
|
|
||||||
|
`infra/preprod/docker-stack.preprod.yml` est versionné et contient **en clair** :
|
||||||
|
mot de passe PostgreSQL, mot de passe Redis, `JWT_SECRET`, identifiants MinIO,
|
||||||
|
**clé SMTP Brevo**, clés Stripe de test, mot de passe administrateur Grafana.
|
||||||
|
|
||||||
|
Ils sont dans l'historique Git : les réécrire ne suffirait pas, il faut les
|
||||||
|
considérer comme **compromis et les révoquer**. La clé Brevo en particulier est
|
||||||
|
un identifiant tiers actif : quiconque a lu le dépôt peut envoyer des e-mails
|
||||||
|
au nom de `noreply@xpeditis.com`.
|
||||||
|
|
||||||
|
→ **[13-securite-durcissement.md § Rotation obligatoire](./13-securite-durcissement.md#rotation-obligatoire-avant-la-mise-en-production)**
|
||||||
|
|
||||||
|
### 2. L'image frontend ne peut pas être promue depuis la preprod — corrigé
|
||||||
|
|
||||||
|
`next.config.js` fige `NEXT_PUBLIC_API_URL` **au moment du build**. L'ancien
|
||||||
|
`cd-main.yml` re-taguait l'image de preprod vers la production : l'application
|
||||||
|
en production aurait appelé `api.preprod.xpeditis.com`. Le workflow réécrit
|
||||||
|
reconstruit le frontend avec les URLs de production et **vérifie que l'URL de
|
||||||
|
preprod n'est pas présente dans le bundle** avant de déployer.
|
||||||
|
|
||||||
|
### 3. Les migrations partaient en concurrence — corrigé
|
||||||
|
|
||||||
|
L'image backend lance les migrations à chaque démarrage de pod
|
||||||
|
(`scripts/setup/startup.js`). Avec deux replicas, deux processus migrent en même
|
||||||
|
temps ; TypeORM ne sérialise pas entre processus, l'un des deux part en
|
||||||
|
`CrashLoopBackOff`. Un **Job Kubernetes** (parallélisme 1) applique désormais les
|
||||||
|
migrations *avant* la mise à jour des images ; `startup.js` ne fait plus que
|
||||||
|
constater qu'il n'y a rien à migrer. **Aucune modification du code applicatif.**
|
||||||
|
|
||||||
|
Un quatrième point ne bloque pas le lancement mais dégrade une fonctionnalité :
|
||||||
|
les notifications temps réel. Voir
|
||||||
|
[15-exploitation-incidents.md § Points de vigilance](./15-exploitation-incidents.md#points-de-vigilance-connus).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chronologie
|
||||||
|
|
||||||
|
| Quand | Étape | Durée | Document |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **J-14** | Comptes, domaine, outils, décisions | 2-3 h | [01](./01-prerequis.md) |
|
||||||
|
| **J-10** | Serveurs, réseau, firewalls (Terraform) | 1 h | [02](./02-provisioning-hetzner.md) |
|
||||||
|
| **J-10** | Durcissement système des deux serveurs | 1 h | [03](./03-durcissement-serveurs.md) |
|
||||||
|
| **J-9** | PostgreSQL + Redis + sauvegardes | 2 h | [04](./04-noeud-donnees.md) |
|
||||||
|
| **J-8** | Cluster k3s | 1 h 30 | [05](./05-cluster-k3s.md) |
|
||||||
|
| **J-8** | Secrets SOPS | 1 h | [06](./06-secrets-sops.md) |
|
||||||
|
| **J-7** | Stockage objet | 45 min | [07](./07-stockage-objet-s3.md) |
|
||||||
|
| **J-7** | DNS, Cloudflare, TLS | 1 h 30 | [08](./08-dns-tls-cloudflare.md) |
|
||||||
|
| **J-6** | Premier déploiement applicatif | 2 h | [09](./09-deploiement-application.md) |
|
||||||
|
| **J-5** | CI/CD automatisée | 1 h 30 | [10](./10-cicd-github-actions.md) |
|
||||||
|
| **J-4** | Supervision et alertes | 2 h | [11](./11-observabilite.md) |
|
||||||
|
| **J-3** | **Test de restauration réel** | 2 h | [12](./12-sauvegardes-restauration.md) |
|
||||||
|
| **J-2** | Revue de sécurité, rotation des secrets | 3 h | [13](./13-securite-durcissement.md) |
|
||||||
|
| **J-1** | Répétition générale, go / no-go | 2 h | [14](./14-runbook-go-live.md) |
|
||||||
|
| **J0** | Ouverture | — | [14](./14-runbook-go-live.md) |
|
||||||
|
| **après** | Exploitation, incidents, montée en charge | — | [15](./15-exploitation-incidents.md) |
|
||||||
|
| **après** | RGPD et conformité | — | [16](./16-rgpd-conformite.md) |
|
||||||
|
|
||||||
|
**Temps de travail cumulé : environ 25 heures.** Étalez-les : plusieurs étapes
|
||||||
|
comportent des délais incompressibles (propagation DNS, émission de certificat,
|
||||||
|
première sauvegarde complète).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les documents
|
||||||
|
|
||||||
|
| # | Fichier | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| 01 | [Prérequis](./01-prerequis.md) | Comptes, outils, domaine, clés SSH, décisions à trancher |
|
||||||
|
| 02 | [Provisioning Hetzner](./02-provisioning-hetzner.md) | Terraform, serveurs, réseau privé, firewalls, volume |
|
||||||
|
| 03 | [Durcissement des serveurs](./03-durcissement-serveurs.md) | SSH, UFW, fail2ban, auditd, mises à jour automatiques |
|
||||||
|
| 04 | [Nœud de données](./04-noeud-donnees.md) | PostgreSQL 15 + TLS, Redis 7, WAL-G, timers de sauvegarde |
|
||||||
|
| 05 | [Cluster k3s](./05-cluster-k3s.md) | Installation durcie, Traefik, cert-manager, kubeconfig |
|
||||||
|
| 06 | [Secrets SOPS](./06-secrets-sops.md) | Clé age, chiffrement, application, rotation, sauvegarde de la clé |
|
||||||
|
| 07 | [Stockage objet](./07-stockage-objet-s3.md) | Hetzner Object Storage, buckets, clés cloisonnées |
|
||||||
|
| 08 | [DNS, TLS, Cloudflare](./08-dns-tls-cloudflare.md) | Enregistrements, proxy, WAF, certificat wildcard |
|
||||||
|
| 09 | [Déploiement applicatif](./09-deploiement-application.md) | Manifests, migrations, premier administrateur, données de référence |
|
||||||
|
| 10 | [CI/CD](./10-cicd-github-actions.md) | Secrets GitHub, environnement protégé, déploiement automatique |
|
||||||
|
| 11 | [Observabilité](./11-observabilite.md) | Loki, Prometheus, Grafana, alertes Discord, supervision externe |
|
||||||
|
| 12 | [Sauvegardes et restauration](./12-sauvegardes-restauration.md) | Stratégie 3-2-1, PITR, tests de restauration, RTO/RPO |
|
||||||
|
| 13 | [Sécurité](./13-securite-durcissement.md) | Rotation des secrets compromis, checklist, revue externe |
|
||||||
|
| 14 | [Runbook de mise en ligne](./14-runbook-go-live.md) | J-1, J0, go/no-go, plan de repli |
|
||||||
|
| 15 | [Exploitation et incidents](./15-exploitation-incidents.md) | Runbooks, points de vigilance, montée en charge, mises à jour |
|
||||||
|
| 16 | [RGPD et conformité](./16-rgpd-conformite.md) | Hébergement, sous-traitants, conservation, droits des personnes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture livrée
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → Cloudflare (WAF, anti-DDoS, cache)
|
||||||
|
│ le firewall Hetzner n'accepte 80/443 que depuis Cloudflare
|
||||||
|
▼
|
||||||
|
app-01 · CPX41 · fsn1 · k3s mono-nœud
|
||||||
|
Traefik ──► api.xpeditis.com → backend NestJS ×2 (HPA 2→4)
|
||||||
|
├► app / www / apex → frontend Next.js ×2
|
||||||
|
└► grafana.xpeditis.com (filtré par IP)
|
||||||
|
observabilité : Loki · Promtail · Prometheus · Alertmanager · Grafana
|
||||||
|
│
|
||||||
|
│ réseau privé 10.10.1.0/24, PostgreSQL en TLS obligatoire
|
||||||
|
▼
|
||||||
|
db-01 · CPX31 · fsn1 · Docker Compose
|
||||||
|
PostgreSQL 15 + WAL-G · Redis 7 · postgres-exporter
|
||||||
|
volume dédié 50 Go
|
||||||
|
│
|
||||||
|
├─► Hetzner Object Storage (documents applicatifs + WAL-G)
|
||||||
|
└─► Hetzner Storage Box (dumps logiques chiffrés age)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principes appliqués partout
|
||||||
|
|
||||||
|
1. **Tout secret est chiffré dans Git** (SOPS + age) ou n'y est pas du tout.
|
||||||
|
2. **La base n'est jamais joignable depuis Internet.** Réseau privé, `hostssl`
|
||||||
|
uniquement, UFW, aucun enregistrement DNS.
|
||||||
|
3. **Personne ne contourne Cloudflare.**
|
||||||
|
4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP.** La CI ouvre une
|
||||||
|
fenêtre de deux minutes pour une seule IP, et la referme quoi qu'il arrive.
|
||||||
|
5. **Une sauvegarde non restaurée n'est pas une sauvegarde.**
|
||||||
|
6. **Ce qui n'est pas surveillé n'existe pas.** Chaque défaillance a une alerte,
|
||||||
|
et le silence des sauvegardes est lui-même surveillé — de l'extérieur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## En cas de problème
|
||||||
|
|
||||||
|
| Situation | Aller directement à |
|
||||||
|
|---|---|
|
||||||
|
| Le site ne répond plus | [15 § Le site est hors ligne](./15-exploitation-incidents.md#le-site-est-hors-ligne) |
|
||||||
|
| Un déploiement a mal tourné | [15 § Retour arrière](./15-exploitation-incidents.md#retour-arriere) |
|
||||||
|
| Perte ou corruption de données | [12 § Restauration](./12-sauvegardes-restauration.md#restauration) |
|
||||||
|
| Suspicion de compromission | [13 § Réponse à incident](./13-securite-durcissement.md#reponse-a-incident) |
|
||||||
|
| Certificat expiré | [08 § Dépannage TLS](./08-dns-tls-cloudflare.md#depannage) |
|
||||||
43
infra/prod/.gitignore
vendored
Normal file
43
infra/prod/.gitignore
vendored
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# === Garde-fou : rien de dechiffre ne doit entrer dans Git ===================
|
||||||
|
# Tout secret est versionne UNIQUEMENT sous forme chiffree SOPS (*.sops.yaml).
|
||||||
|
|
||||||
|
# Fichiers d'environnement en clair
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!*.env.example
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Secrets Kubernetes dechiffres (sortie de `sops -d`)
|
||||||
|
*.dec.yaml
|
||||||
|
*.decrypted.yaml
|
||||||
|
secrets-plain.yaml
|
||||||
|
|
||||||
|
# Cles
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.age
|
||||||
|
age.key
|
||||||
|
age-key.txt
|
||||||
|
id_ed25519*
|
||||||
|
id_rsa*
|
||||||
|
|
||||||
|
# Kubeconfig
|
||||||
|
kubeconfig*
|
||||||
|
*.kubeconfig
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
.terraform/
|
||||||
|
.terraform.lock.hcl
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.*
|
||||||
|
*.tfvars
|
||||||
|
!*.tfvars.example
|
||||||
|
crash.log
|
||||||
|
tfplan*
|
||||||
|
|
||||||
|
# Dumps et backups locaux
|
||||||
|
*.dump
|
||||||
|
*.sql.gz
|
||||||
|
*.tar.zst
|
||||||
|
backups/
|
||||||
40
infra/prod/.sops.yaml
Normal file
40
infra/prod/.sops.yaml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Regles de chiffrement SOPS (age)
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Un seul mecanisme de secrets pour toute la prod : SOPS + age.
|
||||||
|
# Il couvre a la fois les Secrets Kubernetes (noeud app) ET les fichiers .env
|
||||||
|
# du noeud de donnees (Docker Compose) -- ce qu'un controleur type Sealed
|
||||||
|
# Secrets ne saurait pas faire.
|
||||||
|
#
|
||||||
|
# Generation de la cle (UNE SEULE FOIS, cf. docs/mise-en-prod/06-secrets-sops.md) :
|
||||||
|
# age-keygen -o ~/.config/sops/age/keys.txt
|
||||||
|
# grep 'public key' ~/.config/sops/age/keys.txt
|
||||||
|
#
|
||||||
|
# Puis remplacer AGE_RECIPIENT_PRIMARY ci-dessous par la cle publique (age1...).
|
||||||
|
# La cle PRIVEE ne quitte jamais : votre poste + le coffre-fort (1Password /
|
||||||
|
# Bitwarden) + le secret GitHub Actions SOPS_AGE_KEY.
|
||||||
|
#
|
||||||
|
# Ajoutez une 2e recipient (cle de secours, stockee hors ligne, sur papier ou
|
||||||
|
# YubiKey) : sans elle, perdre le poste = perdre tous les secrets de prod.
|
||||||
|
|
||||||
|
creation_rules:
|
||||||
|
# Secrets Kubernetes : on ne chiffre QUE les valeurs sous `data`/`stringData`,
|
||||||
|
# les metadonnees restent lisibles pour pouvoir relire un diff en revue.
|
||||||
|
- path_regex: k8s/.*\.sops\.yaml$
|
||||||
|
encrypted_regex: '^(data|stringData)$'
|
||||||
|
age: >-
|
||||||
|
AGE_RECIPIENT_PRIMARY,
|
||||||
|
AGE_RECIPIENT_BACKUP
|
||||||
|
|
||||||
|
# Fichiers d'environnement du noeud de donnees (docker compose) : tout chiffre.
|
||||||
|
- path_regex: (data-node|env)/.*\.sops\.(yaml|env)$
|
||||||
|
age: >-
|
||||||
|
AGE_RECIPIENT_PRIMARY,
|
||||||
|
AGE_RECIPIENT_BACKUP
|
||||||
|
|
||||||
|
# Filet de securite : tout autre .sops.yaml du dossier prod.
|
||||||
|
- path_regex: \.sops\.yaml$
|
||||||
|
age: >-
|
||||||
|
AGE_RECIPIENT_PRIMARY,
|
||||||
|
AGE_RECIPIENT_BACKUP
|
||||||
140
infra/prod/Makefile
Normal file
140
infra/prod/Makefile
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Xpeditis - production Hetzner
|
||||||
|
# =============================================================================
|
||||||
|
# Toutes les cibles s'executent depuis infra/prod/.
|
||||||
|
# make help
|
||||||
|
#
|
||||||
|
# Les cibles qui touchent la production affichent ce qu'elles vont faire et
|
||||||
|
# demandent confirmation. Aucune ne s'execute par accident.
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
KUBECONFIG ?= $(HOME)/.kube/xpeditis-prod.yaml
|
||||||
|
NAMESPACE ?= xpeditis-prod
|
||||||
|
REGISTRY ?= rg.fr-par.scw.cloud/weworkstudio
|
||||||
|
export KUBECONFIG
|
||||||
|
|
||||||
|
BOLD := \033[1m
|
||||||
|
RESET := \033[0m
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Affiche cette aide
|
||||||
|
@printf '$(BOLD)Xpeditis - production$(RESET)\n\n'
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||||
|
@printf '\nKUBECONFIG : $(KUBECONFIG)\n'
|
||||||
|
|
||||||
|
# --- Infrastructure ---------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: tf-init
|
||||||
|
tf-init: ## Terraform : initialisation
|
||||||
|
cd terraform && terraform init
|
||||||
|
|
||||||
|
.PHONY: tf-plan
|
||||||
|
tf-plan: ## Terraform : previsualise les changements d'infrastructure
|
||||||
|
cd terraform && terraform plan
|
||||||
|
|
||||||
|
.PHONY: tf-apply
|
||||||
|
tf-apply: ## Terraform : applique (cree/modifie les serveurs)
|
||||||
|
@printf '$(BOLD)Cette commande modifie l infrastructure de PRODUCTION.$(RESET)\n'
|
||||||
|
@read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ]
|
||||||
|
cd terraform && terraform apply
|
||||||
|
|
||||||
|
.PHONY: tf-output
|
||||||
|
tf-output: ## Terraform : affiche les IP et les enregistrements DNS a creer
|
||||||
|
cd terraform && terraform output
|
||||||
|
|
||||||
|
.PHONY: cloudflare-ips
|
||||||
|
cloudflare-ips: ## Compare les rangs IP Cloudflare avec firewall.tf
|
||||||
|
bash scripts/refresh-cloudflare-ips.sh
|
||||||
|
|
||||||
|
# --- Secrets ----------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: secrets-edit
|
||||||
|
secrets-edit: ## Edite les secrets chiffres (SOPS ouvre votre editeur)
|
||||||
|
sops k8s/base/03-secrets.sops.yaml
|
||||||
|
|
||||||
|
.PHONY: secrets-apply
|
||||||
|
secrets-apply: ## Applique les secrets sur le cluster
|
||||||
|
bash scripts/secrets-apply.sh
|
||||||
|
|
||||||
|
.PHONY: secrets-check
|
||||||
|
secrets-check: ## Verifie qu'aucun secret en clair n'est pret a etre commite
|
||||||
|
@echo '>>> Fichiers suspects dans infra/prod :'
|
||||||
|
@! git ls-files --others --cached --exclude-standard . \
|
||||||
|
| grep -E '\.(env|key|pem)$$|secrets\.yaml$$|tfvars$$' \
|
||||||
|
| grep -v '\.example$$' \
|
||||||
|
| grep -v '\.sops\.' \
|
||||||
|
|| (echo 'ARRET : des fichiers sensibles sont suivis ou non ignores.'; exit 1)
|
||||||
|
@echo 'Aucun fichier sensible detecte.'
|
||||||
|
|
||||||
|
# --- Deploiement ------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: deploy
|
||||||
|
deploy: ## Deploie une version (make deploy TAG=prod-a1b2c3d)
|
||||||
|
@[ -n "$(TAG)" ] || (echo 'Usage: make deploy TAG=prod-a1b2c3d'; exit 1)
|
||||||
|
bash scripts/deploy.sh $(TAG)
|
||||||
|
|
||||||
|
.PHONY: deploy-monitoring
|
||||||
|
deploy-monitoring: ## Deploie ou met a jour la pile d'observabilite
|
||||||
|
bash scripts/deploy-monitoring.sh
|
||||||
|
|
||||||
|
.PHONY: rollback
|
||||||
|
rollback: ## Revient a la version precedente (backend + frontend)
|
||||||
|
@printf '$(BOLD)Retour arriere de la PRODUCTION.$(RESET)\n'
|
||||||
|
@read -p 'Continuer ? [oui/non] ' r; [ "$$r" = oui ]
|
||||||
|
kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-backend
|
||||||
|
kubectl -n $(NAMESPACE) rollout undo deploy/xpeditis-frontend
|
||||||
|
kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-backend
|
||||||
|
kubectl -n $(NAMESPACE) rollout status deploy/xpeditis-frontend
|
||||||
|
|
||||||
|
.PHONY: restart
|
||||||
|
restart: ## Redemarre les pods applicatifs (prise en compte des secrets)
|
||||||
|
kubectl -n $(NAMESPACE) rollout restart deploy/xpeditis-backend deploy/xpeditis-frontend
|
||||||
|
|
||||||
|
# --- Controles --------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: preflight
|
||||||
|
preflight: ## Controle go / no-go avant ouverture au public
|
||||||
|
bash scripts/preflight-check.sh
|
||||||
|
|
||||||
|
.PHONY: smoke
|
||||||
|
smoke: ## Tests de fumee sur les URLs publiques
|
||||||
|
bash scripts/smoke-test.sh
|
||||||
|
|
||||||
|
.PHONY: status
|
||||||
|
status: ## Vue d'ensemble de la production
|
||||||
|
@printf '\n$(BOLD)Noeuds$(RESET)\n'; kubectl get nodes -o wide
|
||||||
|
@printf '\n$(BOLD)Application$(RESET)\n'; kubectl -n $(NAMESPACE) get pods,deploy,hpa
|
||||||
|
@printf '\n$(BOLD)Ingress$(RESET)\n'; kubectl -n $(NAMESPACE) get ingress
|
||||||
|
@printf '\n$(BOLD)Certificats$(RESET)\n'; kubectl get certificate -A
|
||||||
|
@printf '\n$(BOLD)Observabilite$(RESET)\n'; kubectl -n monitoring get pods
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Journaux du backend en direct
|
||||||
|
kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-backend -f --tail=100 --max-log-requests=6
|
||||||
|
|
||||||
|
.PHONY: logs-frontend
|
||||||
|
logs-frontend: ## Journaux du frontend en direct
|
||||||
|
kubectl -n $(NAMESPACE) logs -l app.kubernetes.io/name=xpeditis-frontend -f --tail=100 --max-log-requests=6
|
||||||
|
|
||||||
|
.PHONY: events
|
||||||
|
events: ## Derniers evenements Kubernetes (diagnostic)
|
||||||
|
kubectl -n $(NAMESPACE) get events --sort-by=.lastTimestamp | tail -40
|
||||||
|
|
||||||
|
# --- Validation locale ------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: validate
|
||||||
|
validate: ## Valide les manifests sans rien appliquer
|
||||||
|
@echo '>>> Validation cote serveur (dry-run)'
|
||||||
|
@for f in k8s/base/*.yaml k8s/monitoring/*.yaml k8s/cluster/*.yaml; do \
|
||||||
|
case "$$f" in *secrets.template.yaml|*migration-job.yaml) continue;; esac; \
|
||||||
|
printf ' %s\n' "$$f"; \
|
||||||
|
kubectl apply --dry-run=server -f "$$f" >/dev/null || exit 1; \
|
||||||
|
done
|
||||||
|
@echo '>>> Terraform'
|
||||||
|
@cd terraform && terraform validate
|
||||||
|
@echo '>>> Scripts shell'
|
||||||
|
@command -v shellcheck >/dev/null && shellcheck -S warning scripts/*.sh data-node/backup/*.sh || echo ' shellcheck absent, ignore'
|
||||||
|
@echo 'Validation terminee.'
|
||||||
152
infra/prod/README.md
Normal file
152
infra/prod/README.md
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
# infra/prod — Production Hetzner
|
||||||
|
|
||||||
|
Tout ce qui est nécessaire pour déployer et exploiter Xpeditis en production.
|
||||||
|
|
||||||
|
> **La procédure pas à pas est dans [`docs/mise-en-prod/`](../../docs/mise-en-prod/README.md).**
|
||||||
|
> Ce README décrit *ce que contient le dossier* ; la doc décrit *quoi faire, dans quel ordre*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture cible
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
|
|
||||||
|
Cloudflare (DNS + WAF + anti-DDoS)
|
||||||
|
| seules les IP Cloudflare passent le firewall
|
||||||
|
v
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ app-01 CPX41 8 vCPU / 16 Go fsn1 │
|
||||||
|
│ │
|
||||||
|
│ k3s (mono-nœud) │
|
||||||
|
│ Traefik ──┬── api.xpeditis.com → backend │
|
||||||
|
│ ├── app / www / apex → frontend│
|
||||||
|
│ └── grafana.xpeditis.com │
|
||||||
|
│ backend NestJS x2 (HPA 2→4) │
|
||||||
|
│ frontend Next.js x2 │
|
||||||
|
│ log-exporter │
|
||||||
|
│ monitoring : Loki, Promtail, Prometheus, │
|
||||||
|
│ Alertmanager, Grafana │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
│ réseau privé 10.10.1.0/24
|
||||||
|
│ TLS obligatoire (hostssl)
|
||||||
|
v
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ db-01 CPX31 4 vCPU / 8 Go fsn1 │
|
||||||
|
│ │
|
||||||
|
│ PostgreSQL 15 + WAL-G (Docker Compose) │
|
||||||
|
│ Redis 7 │
|
||||||
|
│ postgres-exporter │
|
||||||
|
│ volume dédié 50 Go │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────┴────────────┐
|
||||||
|
v v
|
||||||
|
Hetzner Object Storage Hetzner Storage Box
|
||||||
|
(documents + WAL-G) (dumps chiffrés age)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pourquoi la base hors de Kubernetes** — PostgreSQL en StatefulSet ajoute des
|
||||||
|
volumes persistants, un ordre de démarrage et des montées de version délicates,
|
||||||
|
sans aucun gain à cette échelle. Hors cluster, les sauvegardes, la restauration
|
||||||
|
à un instant T et les tests de restauration sont triviaux, et le nœud
|
||||||
|
applicatif reste entièrement reconstructible sans toucher aux données.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contenu du dossier
|
||||||
|
|
||||||
|
| Chemin | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `terraform/` | Serveurs, réseau privé, firewalls, volume, clé SSH. La seule source de vérité de l'infrastructure. |
|
||||||
|
| `scripts/00-bootstrap-common.sh` | Durcissement système commun (SSH, UFW, fail2ban, auditd, sysctl, mises à jour auto). |
|
||||||
|
| `scripts/01-setup-data-node.sh` | Installe db-01 : volume, Docker, certificat TLS Postgres, timers de sauvegarde. |
|
||||||
|
| `scripts/02-setup-k3s-server.sh` | Installe k3s durci (secrets chiffrés au repos, audit API, Traefik configuré). |
|
||||||
|
| `scripts/03-install-cluster-addons.sh` | cert-manager, namespaces, accès registre, ClusterIssuer. |
|
||||||
|
| `scripts/secrets-apply.sh` | Déchiffre SOPS → applique sur le cluster, sans passer par le disque. |
|
||||||
|
| `scripts/harden-seed-data.sh` | Secours et audit : neutralise les comptes de démonstration (`admin@xpeditis.com` / `Password123!`) sur une base migrée avant l'ajout de la garde `NODE_ENV`. Le cas nominal est traité par les migrations. |
|
||||||
|
| `scripts/deploy.sh` | Déploiement complet : migrations → images → attente → tests → retour arrière. |
|
||||||
|
| `scripts/ssh-deploy-wrapper.sh` | Restreint la clé SSH de la CI à quatre commandes. Une clé volée ne donne pas un shell. |
|
||||||
|
| `scripts/deploy-monitoring.sh` | Pile d'observabilité. |
|
||||||
|
| `scripts/smoke-test.sh` | Vérifie ce qu'un utilisateur constate réellement, de l'extérieur. |
|
||||||
|
| `scripts/preflight-check.sh` | Contrôle go / no-go avant ouverture au public. |
|
||||||
|
| `scripts/refresh-cloudflare-ips.sh` | Met à jour les rangs IP Cloudflare (firewall + Traefik). |
|
||||||
|
| `data-node/` | Docker Compose, `postgresql.conf`, `pg_hba.conf`, `redis.conf`, sauvegardes WAL-G. |
|
||||||
|
| `k8s/base/` | Namespaces, quotas, ConfigMap, gabarit de secrets, déploiements, Ingress, middlewares, politiques réseau, certificats. |
|
||||||
|
| `k8s/cluster/` | ClusterIssuer Let's Encrypt (DNS-01 Cloudflare). |
|
||||||
|
| `k8s/monitoring/` | Loki, Promtail, Prometheus, Alertmanager, Grafana. |
|
||||||
|
| `env/` | Gabarits de variables d'environnement et liste des secrets GitHub. |
|
||||||
|
| `cloudflare/` | Enregistrements DNS et règles WAF à créer côté Cloudflare. |
|
||||||
|
| `Makefile` | Raccourcis d'exploitation (`make help`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage rapide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd infra/prod
|
||||||
|
make help
|
||||||
|
|
||||||
|
# Infrastructure
|
||||||
|
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
|
||||||
|
$EDITOR terraform/terraform.tfvars
|
||||||
|
make tf-init && make tf-plan && make tf-apply
|
||||||
|
make tf-output # IP + enregistrements DNS à créer
|
||||||
|
|
||||||
|
# ... provisioning des serveurs : voir docs/mise-en-prod/03 et 04 ...
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
|
||||||
|
$EDITOR /tmp/secrets.yaml
|
||||||
|
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml && shred -u /tmp/secrets.yaml
|
||||||
|
make secrets-apply
|
||||||
|
|
||||||
|
# Déploiement
|
||||||
|
make deploy TAG=prod-a1b2c3d
|
||||||
|
make deploy-monitoring
|
||||||
|
|
||||||
|
# Avant d'ouvrir au public
|
||||||
|
make preflight
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles de sécurité non négociables
|
||||||
|
|
||||||
|
1. **Aucun secret en clair dans Git.** Uniquement des fichiers `*.sops.yaml`
|
||||||
|
chiffrés avec age. `make secrets-check` refuse le contraire.
|
||||||
|
2. **La base de données n'est jamais joignable depuis Internet.** Réseau privé,
|
||||||
|
bind explicite sur l'IP privée, UFW, et `pg_hba` en `hostssl` seulement.
|
||||||
|
3. **Personne ne contourne Cloudflare.** Le firewall Hetzner n'accepte 80/443
|
||||||
|
que depuis les rangs Cloudflare.
|
||||||
|
4. **SSH et l'API Kubernetes ne sont ouverts qu'à vos IP d'administration.**
|
||||||
|
Jamais `0.0.0.0/0` — Terraform refuse cette valeur.
|
||||||
|
5. **Une sauvegarde non restaurée n'est pas une sauvegarde.** Le test de
|
||||||
|
restauration tourne toutes les semaines et doit être passé avant l'ouverture.
|
||||||
|
6. **Les migrations passent par un Job**, jamais en concurrence entre replicas.
|
||||||
|
7. **L'image frontend est reconstruite pour la production**, jamais promue
|
||||||
|
depuis la preprod : `NEXT_PUBLIC_API_URL` est figée au build.
|
||||||
|
8. **Aucun mot de passe d'administrateur n'est stocké nulle part.**
|
||||||
|
`SeedTestUsers` ne s'exécute plus en production, une migration de secours
|
||||||
|
neutralise ses comptes s'ils existent, et le premier administrateur est créé
|
||||||
|
sans mot de passe utilisable — vous définissez le vôtre via « mot de passe
|
||||||
|
oublié ».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coûts (référence : `Xpeditis_Previsions_Couts.xlsx`, feuille « Hetzner »)
|
||||||
|
|
||||||
|
| Poste | € HT / mois |
|
||||||
|
|---|---|
|
||||||
|
| app-01 CPX41 | 29 |
|
||||||
|
| db-01 CPX31 | 15 |
|
||||||
|
| Volume 50 Go | 2,40 |
|
||||||
|
| Sauvegardes Hetzner (20 %) | 8,80 |
|
||||||
|
| Storage Box BX11 (1 To) | 3,90 |
|
||||||
|
| Object Storage (documents + WAL-G, < 1 To) | ~6 |
|
||||||
|
| IPv4 supplémentaire | 1 |
|
||||||
|
| Cloudflare Free | 0 |
|
||||||
|
| **Total infrastructure** | **≈ 66 €** |
|
||||||
|
|
||||||
|
Les services tiers (Brevo, Stripe, Sentry, Pappers, assurance) s'ajoutent :
|
||||||
|
voir la feuille « Synthèse comparatif » du fichier de prévisions.
|
||||||
171
infra/prod/cloudflare/README.md
Normal file
171
infra/prod/cloudflare/README.md
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
# Configuration Cloudflare — production
|
||||||
|
|
||||||
|
Cloudflare est la première ligne de défense : DNS, anti-DDoS, WAF, cache.
|
||||||
|
Le firewall Hetzner n'accepte 80/443 **que** depuis les rangs Cloudflare, donc
|
||||||
|
si le proxy est désactivé sur un enregistrement, ce domaine devient
|
||||||
|
inaccessible. C'est voulu : personne ne doit pouvoir taper l'IP d'origine.
|
||||||
|
|
||||||
|
Le plan Free suffit en phase 1 (≈ 100 utilisateurs). Le plan Pro (20 €/mois,
|
||||||
|
prévu au budget à partir de 1 000 utilisateurs) ajoute le WAF managé et
|
||||||
|
l'analyse de bots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Enregistrements DNS
|
||||||
|
|
||||||
|
Toutes les valeurs `<IP_APP_01>` viennent de `make tf-output`.
|
||||||
|
|
||||||
|
| Type | Nom | Contenu | Proxy | TTL |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A | `xpeditis.com` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||||
|
| A | `www` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||||
|
| A | `app` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||||
|
| A | `api` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||||
|
| A | `grafana` | `<IP_APP_01>` | **Proxifié** | Auto |
|
||||||
|
| AAAA | idem si IPv6 activée | `<IPv6_APP_01>` | **Proxifié** | Auto |
|
||||||
|
|
||||||
|
**Aucun enregistrement ne pointe vers db-01.** Son IP publique ne sert qu'au
|
||||||
|
SSH d'administration et ne doit apparaître nulle part en DNS.
|
||||||
|
|
||||||
|
### Messagerie (obligatoire pour que les e-mails arrivent)
|
||||||
|
|
||||||
|
| Type | Nom | Contenu |
|
||||||
|
|---|---|---|
|
||||||
|
| TXT | `xpeditis.com` | `v=spf1 include:spf.brevo.com -all` |
|
||||||
|
| TXT | `mail._domainkey` | clé DKIM fournie par Brevo |
|
||||||
|
| TXT | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@xpeditis.com; pct=100` |
|
||||||
|
|
||||||
|
Sans SPF/DKIM/DMARC, les confirmations de réservation et les liens magiques
|
||||||
|
transporteurs partent en spam — un défaut de production silencieux et coûteux.
|
||||||
|
Commencez à `p=quarantine`, passez à `p=reject` après deux semaines de rapports
|
||||||
|
propres.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Réglages SSL/TLS
|
||||||
|
|
||||||
|
| Réglage | Valeur | Pourquoi |
|
||||||
|
|---|---|---|
|
||||||
|
| Mode de chiffrement | **Full (strict)** | Cloudflare vérifie le certificat Let's Encrypt du serveur. En « Flexible », le trafic Cloudflare → origine serait en clair. |
|
||||||
|
| Always Use HTTPS | Activé | |
|
||||||
|
| Minimum TLS Version | **1.2** | TLS 1.0/1.1 sont cassés et refusés par la plupart des référentiels de conformité. |
|
||||||
|
| TLS 1.3 | Activé | |
|
||||||
|
| Automatic HTTPS Rewrites | Activé | |
|
||||||
|
| HSTS | **Activé après vérification** | Voir l'avertissement ci-dessous. |
|
||||||
|
|
||||||
|
> **HSTS est irréversible pendant un an.** Ne l'activez qu'une fois certain que
|
||||||
|
> *tous* les sous-domaines servent bien du HTTPS valide. Traefik pose déjà
|
||||||
|
> l'en-tête (`k8s/base/08-traefik-middlewares.yaml`) ; l'activer aussi côté
|
||||||
|
> Cloudflare ajoute la protection avant même que la requête n'atteigne le
|
||||||
|
> serveur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Règles WAF
|
||||||
|
|
||||||
|
### 3.1 Bloquer l'accès direct aux chemins d'administration
|
||||||
|
|
||||||
|
```
|
||||||
|
(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/docs"))
|
||||||
|
```
|
||||||
|
→ Action : **Block**
|
||||||
|
|
||||||
|
La documentation Swagger est déjà désactivée en production par `main.ts`, mais
|
||||||
|
une règle de bordure protège même en cas d'erreur de configuration.
|
||||||
|
|
||||||
|
### 3.2 Limiter les tentatives de connexion
|
||||||
|
|
||||||
|
Rate limiting (Security → WAF → Rate limiting rules) :
|
||||||
|
|
||||||
|
| Champ | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Expression | `starts_with(http.request.uri.path, "/api/v1/auth/login")` |
|
||||||
|
| Requêtes | 10 |
|
||||||
|
| Période | 1 minute |
|
||||||
|
| Par | Adresse IP |
|
||||||
|
| Action | Block, 10 minutes |
|
||||||
|
|
||||||
|
Cette limite s'ajoute à celle de Traefik (`rate-limit-auth`) et à celle de
|
||||||
|
NestJS (`CustomThrottlerGuard`). Trois couches indépendantes : une erreur de
|
||||||
|
configuration sur l'une ne laisse pas la porte ouverte.
|
||||||
|
|
||||||
|
### 3.3 Protéger le webhook Stripe
|
||||||
|
|
||||||
|
```
|
||||||
|
(http.host eq "api.xpeditis.com" and starts_with(http.request.uri.path, "/api/v1/subscriptions/webhook") and not ip.src in {IP_STRIPE})
|
||||||
|
```
|
||||||
|
→ Action : **Block**
|
||||||
|
|
||||||
|
Les rangs d'IP Stripe sont publiés sur
|
||||||
|
`https://stripe.com/files/ips/ips_webhooks.txt`. La signature du webhook est
|
||||||
|
déjà vérifiée côté applicatif ; ceci évite simplement que du bruit atteigne
|
||||||
|
l'application.
|
||||||
|
|
||||||
|
### 3.4 Filtrage géographique — à décider consciemment
|
||||||
|
|
||||||
|
Xpeditis sert des transitaires européens. Bloquer tout sauf l'Europe réduit
|
||||||
|
massivement le bruit, mais coupe aussi les transitaires asiatiques légitimes
|
||||||
|
(les connecteurs transporteurs partent du serveur, ils ne sont pas concernés).
|
||||||
|
|
||||||
|
**Recommandation** : ne pas bloquer par pays au lancement. Activer plutôt
|
||||||
|
« Bot Fight Mode » (gratuit) et surveiller les journaux un mois avant de
|
||||||
|
décider.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cache
|
||||||
|
|
||||||
|
| Chemin | Règle |
|
||||||
|
|---|---|
|
||||||
|
| `api.xpeditis.com/*` | **Bypass cache** — une réponse d'API mise en cache servirait les données d'un utilisateur à un autre. |
|
||||||
|
| `app.xpeditis.com/_next/static/*` | Cache Everything, Edge TTL 1 an (contenu au nom versionné). |
|
||||||
|
| `app.xpeditis.com/*` | Standard (Next.js gère ses propres en-têtes). |
|
||||||
|
|
||||||
|
> La règle de bypass sur l'API est **critique**. Un cache mal placé sur une
|
||||||
|
> route authentifiée est une fuite de données, pas un problème de performance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Jeton API pour cert-manager
|
||||||
|
|
||||||
|
`My Profile → API Tokens → Create Token → Edit zone DNS`
|
||||||
|
|
||||||
|
| Permission | Portée |
|
||||||
|
|---|---|
|
||||||
|
| Zone / DNS / Edit | Zone **xpeditis.com** uniquement |
|
||||||
|
| Zone / Zone / Read | Zone **xpeditis.com** uniquement |
|
||||||
|
|
||||||
|
N'utilisez **jamais** la clé globale du compte : elle permettrait à
|
||||||
|
cert-manager — ou à quiconque lirait le Secret — de rediriger tout votre trafic.
|
||||||
|
|
||||||
|
Le jeton alimente le Secret `cert-manager/cloudflare-api-token`
|
||||||
|
(`k8s/base/03-secrets.template.yaml`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Accès à Grafana
|
||||||
|
|
||||||
|
Deux options, au choix :
|
||||||
|
|
||||||
|
- **Liste d'IP** (par défaut) — middleware Traefik `admin-ip-allowlist`,
|
||||||
|
à renseigner dans `k8s/base/08-traefik-middlewares.yaml`.
|
||||||
|
- **Cloudflare Access** (Zero Trust, gratuit jusqu'à 50 utilisateurs) —
|
||||||
|
authentification par e-mail à usage unique devant `grafana.xpeditis.com`.
|
||||||
|
Préférable si votre IP est dynamique.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Vérification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Le proxy est-il bien actif ? (l'IP retournée doit être une IP Cloudflare)
|
||||||
|
dig +short app.xpeditis.com
|
||||||
|
|
||||||
|
# L'origine est-elle injoignable en direct ?
|
||||||
|
curl -sS --max-time 5 --connect-to app.xpeditis.com:443:<IP_APP_01>:443 \
|
||||||
|
https://app.xpeditis.com/ # doit échouer par timeout
|
||||||
|
|
||||||
|
# SPF / DKIM / DMARC
|
||||||
|
dig +short TXT xpeditis.com
|
||||||
|
dig +short TXT _dmarc.xpeditis.com
|
||||||
|
```
|
||||||
29
infra/prod/data-node/Dockerfile.postgres
Normal file
29
infra/prod/data-node/Dockerfile.postgres
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# PostgreSQL 15 + WAL-G
|
||||||
|
# =============================================================================
|
||||||
|
# Image postgres officielle enrichie de WAL-G, qui assure :
|
||||||
|
# - l'archivage continu des WAL vers Hetzner Object Storage (archive_command)
|
||||||
|
# - les sauvegardes physiques completes (basebackup)
|
||||||
|
# - la restauration a un instant T (PITR)
|
||||||
|
#
|
||||||
|
# On epingle une version precise de WAL-G : une mise a jour silencieuse de
|
||||||
|
# l'outil de sauvegarde est exactement ce qu'on ne veut pas decouvrir un jour
|
||||||
|
# de restauration.
|
||||||
|
|
||||||
|
FROM postgres:15-bookworm
|
||||||
|
|
||||||
|
ARG WALG_VERSION=v3.0.5
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
|
||||||
|
RUN set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates curl; \
|
||||||
|
curl -fsSL -o /tmp/wal-g.tar.gz \
|
||||||
|
"https://github.com/wal-g/wal-g/releases/download/${WALG_VERSION}/wal-g-pg-ubuntu-22.04-${TARGETARCH}.tar.gz"; \
|
||||||
|
tar -xzf /tmp/wal-g.tar.gz -C /tmp; \
|
||||||
|
mv "/tmp/wal-g-pg-ubuntu-22.04-${TARGETARCH}" /usr/local/bin/wal-g; \
|
||||||
|
chmod 0755 /usr/local/bin/wal-g; \
|
||||||
|
rm -rf /tmp/wal-g.tar.gz /var/lib/apt/lists/*; \
|
||||||
|
wal-g --version
|
||||||
|
|
||||||
|
# Les scripts de sauvegarde / restauration sont montes depuis backup/.
|
||||||
134
infra/prod/data-node/backup/pg-backup.sh
Executable file
134
infra/prod/data-node/backup/pg-backup.sh
Executable file
@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Sauvegarde PostgreSQL - production Xpeditis
|
||||||
|
# =============================================================================
|
||||||
|
# Deux mecanismes complementaires, volontairement redondants :
|
||||||
|
#
|
||||||
|
# 1. WAL-G basebackup + archivage WAL continu -> Hetzner Object Storage
|
||||||
|
# Objectif : restauration a un instant T (PITR). RPO <= 5 min.
|
||||||
|
# C'est la sauvegarde qui compte en cas de corruption ou d'erreur humaine
|
||||||
|
# ("j'ai supprime les bookings de mars").
|
||||||
|
#
|
||||||
|
# 2. pg_dump logique chiffre -> Hetzner Storage Box (autre service, autre
|
||||||
|
# credential, autre protocole).
|
||||||
|
# Objectif : survivre a une compromission du compte Object Storage, a un
|
||||||
|
# bug WAL-G, ou a une montee de version majeure de PostgreSQL.
|
||||||
|
#
|
||||||
|
# Regle des 3-2-1 : 3 copies (volume + Object Storage + Storage Box), 2 supports
|
||||||
|
# distincts, 1 hors du perimetre de la VM. Cf. 12-sauvegardes-restauration.md.
|
||||||
|
#
|
||||||
|
# Execution : timer systemd `xpeditis-backup.timer`, tous les jours a 02h30.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
||||||
|
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
||||||
|
DUMP_DIR="${DUMP_DIR:-/var/lib/xpeditis/dumps}"
|
||||||
|
LOG_TAG="xpeditis-backup"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; source "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
log() { logger -t "$LOG_TAG" -- "$*"; printf '[%s] %s\n' "$(date -Is)" "$*"; }
|
||||||
|
fail() { log "ECHEC: $*"; notify "ECHEC sauvegarde PostgreSQL" "$*"; exit 1; }
|
||||||
|
|
||||||
|
notify() {
|
||||||
|
# Alerte Discord (meme webhook que la CI/CD). Une sauvegarde qui echoue en
|
||||||
|
# silence est pire que pas de sauvegarde du tout.
|
||||||
|
local title="$1" body="$2"
|
||||||
|
[[ -n "${DISCORD_WEBHOOK_URL:-}" ]] || return 0
|
||||||
|
curl -sf -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg t "$title" --arg d "$body" \
|
||||||
|
'{embeds:[{title:$t,description:$d,color:15158332}]}')" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" >/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
|
||||||
|
|
||||||
|
# --- 0. Preconditions --------------------------------------------------------
|
||||||
|
command -v age >/dev/null || fail "age n'est pas installe (chiffrement des dumps)"
|
||||||
|
[[ -n "${BACKUP_AGE_RECIPIENT:-}" ]] || fail "BACKUP_AGE_RECIPIENT absent de .env.data"
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
|
||||||
|
dc ps --status running --services | grep -qx postgres \
|
||||||
|
|| fail "le conteneur postgres n'est pas demarre"
|
||||||
|
|
||||||
|
# --- 1. Sauvegarde physique WAL-G (base de la PITR) --------------------------
|
||||||
|
log "WAL-G basebackup : demarrage"
|
||||||
|
if ! dc exec -T -u postgres postgres wal-g backup-push /var/lib/postgresql/data/pgdata; then
|
||||||
|
fail "wal-g backup-push a echoue"
|
||||||
|
fi
|
||||||
|
log "WAL-G basebackup : termine"
|
||||||
|
|
||||||
|
# --- 2. Retention WAL-G ------------------------------------------------------
|
||||||
|
# 7 sauvegardes completes conservees (soit ~1 semaine de PITR a raison d'une
|
||||||
|
# par jour), les WAL anterieurs sont purges avec elles.
|
||||||
|
log "WAL-G : purge des sauvegardes au-dela de 7 retentions"
|
||||||
|
dc exec -T -u postgres postgres wal-g delete retain FULL 7 --confirm \
|
||||||
|
|| log "AVERTISSEMENT: la purge WAL-G a echoue (non bloquant)"
|
||||||
|
|
||||||
|
# --- 3. Dump logique chiffre -------------------------------------------------
|
||||||
|
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
DUMP_NAME="xpeditis-${STAMP}.dump"
|
||||||
|
DUMP_PATH="${DUMP_DIR}/${DUMP_NAME}"
|
||||||
|
|
||||||
|
log "pg_dump logique : demarrage"
|
||||||
|
# -Fc = format custom : compresse, restaurable table par table avec pg_restore.
|
||||||
|
if ! dc exec -T -u postgres postgres \
|
||||||
|
pg_dump -Fc -Z6 --no-owner --no-privileges \
|
||||||
|
-d "$POSTGRES_DB" > "$DUMP_PATH"; then
|
||||||
|
rm -f "$DUMP_PATH"
|
||||||
|
fail "pg_dump a echoue"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DUMP_SIZE=$(stat -c%s "$DUMP_PATH")
|
||||||
|
# Un dump anormalement petit signale une base vide ou une erreur silencieuse.
|
||||||
|
if (( DUMP_SIZE < 51200 )); then
|
||||||
|
rm -f "$DUMP_PATH"
|
||||||
|
fail "dump suspect : ${DUMP_SIZE} octets (< 50 Ko)"
|
||||||
|
fi
|
||||||
|
log "pg_dump : ${DUMP_SIZE} octets"
|
||||||
|
|
||||||
|
log "Chiffrement age"
|
||||||
|
age -r "$BACKUP_AGE_RECIPIENT" -o "${DUMP_PATH}.age" "$DUMP_PATH" \
|
||||||
|
|| fail "chiffrement age echoue"
|
||||||
|
# Le dump en clair ne survit pas a la minute : seule la version chiffree part
|
||||||
|
# sur le reseau et reste sur disque.
|
||||||
|
shred -u "$DUMP_PATH" 2>/dev/null || rm -f "$DUMP_PATH"
|
||||||
|
sha256sum "${DUMP_PATH}.age" > "${DUMP_PATH}.age.sha256"
|
||||||
|
|
||||||
|
# --- 4. Copie vers la Storage Box -------------------------------------------
|
||||||
|
if [[ -n "${STORAGEBOX_HOST:-}" ]]; then
|
||||||
|
log "Envoi vers la Storage Box ${STORAGEBOX_HOST}"
|
||||||
|
rsync -a --protect-args \
|
||||||
|
-e "ssh -p ${STORAGEBOX_PORT:-23} -i ${STORAGEBOX_SSH_KEY:-/root/.ssh/storagebox} -o StrictHostKeyChecking=yes" \
|
||||||
|
"${DUMP_PATH}.age" "${DUMP_PATH}.age.sha256" \
|
||||||
|
"${STORAGEBOX_USER}@${STORAGEBOX_HOST}:./xpeditis-prod/dumps/" \
|
||||||
|
|| fail "rsync vers la Storage Box a echoue"
|
||||||
|
else
|
||||||
|
log "AVERTISSEMENT: STORAGEBOX_HOST non configure, pas de seconde copie hors VM"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. Retention locale -----------------------------------------------------
|
||||||
|
# 7 jours en local : de quoi restaurer vite sans re-telecharger. La retention
|
||||||
|
# longue (30 jours + 12 mensuels) vit sur la Storage Box.
|
||||||
|
find "$DUMP_DIR" -name 'xpeditis-*.dump.age*' -mtime +7 -delete
|
||||||
|
log "Retention locale appliquee (7 jours)"
|
||||||
|
|
||||||
|
# --- 6. Compte rendu ---------------------------------------------------------
|
||||||
|
LATEST=$(dc exec -T -u postgres postgres wal-g backup-list --detail 2>/dev/null | tail -n 1 || echo "?")
|
||||||
|
log "Sauvegarde terminee. Derniere sauvegarde WAL-G: ${LATEST}"
|
||||||
|
|
||||||
|
# Battement de coeur externe : c'est le SILENCE qui declenche l'alerte. Une
|
||||||
|
# supervision hebergee sur cette meme machine ne dirait rien si la machine
|
||||||
|
# etait eteinte -- exactement le cas ou l'alerte compte.
|
||||||
|
if [[ -n "${BACKUP_HEARTBEAT_URL:-}" ]]; then
|
||||||
|
curl -fsS --max-time 10 --retry 3 "$BACKUP_HEARTBEAT_URL" >/dev/null \
|
||||||
|
|| log "AVERTISSEMENT: le battement de coeur n'a pas pu etre envoye"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${DISCORD_WEBHOOK_URL:-}" ]]; then
|
||||||
|
curl -sf -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg d "Dump: ${DUMP_NAME}.age ($(numfmt --to=iec "$DUMP_SIZE"))" \
|
||||||
|
'{embeds:[{title:"Sauvegarde PostgreSQL OK",description:$d,color:3066993}]}')" \
|
||||||
|
"$DISCORD_WEBHOOK_URL" >/dev/null || true
|
||||||
|
fi
|
||||||
192
infra/prod/data-node/backup/pg-restore.sh
Executable file
192
infra/prod/data-node/backup/pg-restore.sh
Executable file
@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Restauration PostgreSQL - production Xpeditis
|
||||||
|
# =============================================================================
|
||||||
|
# Trois modes, du moins au plus destructeur :
|
||||||
|
#
|
||||||
|
# verify <fichier.dump.age> Restaure dans une base jetable et controle
|
||||||
|
# la coherence. AUCUN impact sur la prod.
|
||||||
|
# C'est ce que lance le timer hebdomadaire.
|
||||||
|
#
|
||||||
|
# logical <fichier.dump.age> Restaure un dump logique dans une base
|
||||||
|
# nommee (par defaut une base de secours).
|
||||||
|
#
|
||||||
|
# pitr <YYYY-MM-DD HH:MM:SS> Restauration a un instant T via WAL-G.
|
||||||
|
# DETRUIT le repertoire de donnees courant.
|
||||||
|
# Reserve aux incidents majeurs.
|
||||||
|
#
|
||||||
|
# Lire docs/mise-en-prod/12-sauvegardes-restauration.md AVANT d'utiliser `pitr`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
||||||
|
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
||||||
|
PGDATA_HOST="${PGDATA_HOST:-/var/lib/xpeditis/pgdata}"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; source "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
log() { printf '\n[%s] %s\n' "$(date -Is)" "$*"; }
|
||||||
|
fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; }
|
||||||
|
dc() { docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" "$@"; }
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
local prompt="$1" expected="$2" answer
|
||||||
|
printf '\n%s\nTapez exactement "%s" pour confirmer : ' "$prompt" "$expected"
|
||||||
|
read -r answer
|
||||||
|
[[ "$answer" == "$expected" ]] || fail "confirmation refusee"
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypt_dump() {
|
||||||
|
local encrypted="$1" out="$2"
|
||||||
|
[[ -f "$encrypted" ]] || fail "fichier introuvable : $encrypted"
|
||||||
|
[[ -f "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" ]] \
|
||||||
|
|| fail "cle de dechiffrement absente (BACKUP_AGE_KEY_FILE)"
|
||||||
|
age -d -i "${BACKUP_AGE_KEY_FILE:-/root/.config/xpeditis/backup-age.key}" \
|
||||||
|
-o "$out" "$encrypted" || fail "dechiffrement age echoue"
|
||||||
|
}
|
||||||
|
|
||||||
|
MODE="${1:-}"
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
verify)
|
||||||
|
ENCRYPTED="${2:-$(ls -1t /var/lib/xpeditis/dumps/xpeditis-*.dump.age 2>/dev/null | head -1)}"
|
||||||
|
[[ -n "$ENCRYPTED" ]] || fail "aucun dump trouve"
|
||||||
|
SCRATCH_DB="xpeditis_restore_check"
|
||||||
|
TMP_DUMP="/tmp/xpeditis-verify-$$.dump"
|
||||||
|
|
||||||
|
log "Verification de : $ENCRYPTED"
|
||||||
|
decrypt_dump "$ENCRYPTED" "$TMP_DUMP"
|
||||||
|
trap 'rm -f "$TMP_DUMP"' EXIT
|
||||||
|
|
||||||
|
log "Creation de la base jetable ${SCRATCH_DB}"
|
||||||
|
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${SCRATCH_DB};"
|
||||||
|
dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${SCRATCH_DB};"
|
||||||
|
|
||||||
|
log "pg_restore dans la base jetable"
|
||||||
|
dc exec -T -u postgres postgres pg_restore \
|
||||||
|
--no-owner --no-privileges --exit-on-error -d "$SCRATCH_DB" < "$TMP_DUMP" \
|
||||||
|
|| fail "pg_restore a echoue : LA SAUVEGARDE EST INEXPLOITABLE"
|
||||||
|
|
||||||
|
log "Controles de coherence"
|
||||||
|
# Une sauvegarde qui se restaure mais ne contient rien ne vaut rien : on
|
||||||
|
# verifie que les tables metier critiques sont peuplees.
|
||||||
|
dc exec -T -u postgres postgres psql -d "$SCRATCH_DB" -v ON_ERROR_STOP=1 <<'SQL'
|
||||||
|
\pset pager off
|
||||||
|
SELECT 'tables' AS objet, count(*) AS n FROM information_schema.tables WHERE table_schema='public';
|
||||||
|
SELECT 'users' AS objet, count(*) AS n FROM users;
|
||||||
|
SELECT 'organizations' AS objet, count(*) AS n FROM organizations;
|
||||||
|
SELECT 'csv_bookings' AS objet, count(*) AS n FROM csv_bookings;
|
||||||
|
SELECT 'migrations' AS objet, count(*) AS n FROM migrations;
|
||||||
|
DO $$
|
||||||
|
DECLARE t int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO t FROM information_schema.tables WHERE table_schema='public';
|
||||||
|
IF t < 10 THEN
|
||||||
|
RAISE EXCEPTION 'Sauvegarde suspecte : seulement % tables restaurees', t;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
log "Nettoyage"
|
||||||
|
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE ${SCRATCH_DB};"
|
||||||
|
log "SAUVEGARDE VALIDE : $ENCRYPTED"
|
||||||
|
;;
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
logical)
|
||||||
|
ENCRYPTED="${2:?usage: $0 logical <fichier.dump.age> [base_cible]}"
|
||||||
|
TARGET_DB="${3:-${POSTGRES_DB}_restored}"
|
||||||
|
TMP_DUMP="/tmp/xpeditis-restore-$$.dump"
|
||||||
|
|
||||||
|
confirm "Restauration logique de $ENCRYPTED dans la base '${TARGET_DB}'. La base cible sera SUPPRIMEE si elle existe." "RESTAURER ${TARGET_DB}"
|
||||||
|
|
||||||
|
decrypt_dump "$ENCRYPTED" "$TMP_DUMP"
|
||||||
|
trap 'rm -f "$TMP_DUMP"' EXIT
|
||||||
|
|
||||||
|
dc exec -T -u postgres postgres psql -q -c "DROP DATABASE IF EXISTS ${TARGET_DB};"
|
||||||
|
dc exec -T -u postgres postgres psql -q -c "CREATE DATABASE ${TARGET_DB};"
|
||||||
|
dc exec -T -u postgres postgres pg_restore \
|
||||||
|
--no-owner --no-privileges --exit-on-error -j 2 -d "$TARGET_DB" < "$TMP_DUMP" \
|
||||||
|
|| fail "pg_restore a echoue"
|
||||||
|
|
||||||
|
log "Restauration terminee dans '${TARGET_DB}'."
|
||||||
|
log "La base de production n'a PAS ete touchee. Pour basculer :"
|
||||||
|
log " 1. arreter le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=0"
|
||||||
|
log " 2. renommer les bases : ALTER DATABASE ${POSTGRES_DB} RENAME TO ${POSTGRES_DB}_old;"
|
||||||
|
log " ALTER DATABASE ${TARGET_DB} RENAME TO ${POSTGRES_DB};"
|
||||||
|
log " 3. relancer le backend : kubectl -n xpeditis-prod scale deploy/xpeditis-backend --replicas=2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pitr)
|
||||||
|
TARGET_TIME="${2:?usage: $0 pitr \"YYYY-MM-DD HH:MM:SS\"}"
|
||||||
|
|
||||||
|
cat <<BANNER
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# RESTAURATION A UN INSTANT T (PITR) #
|
||||||
|
# #
|
||||||
|
# Cette operation DETRUIT le repertoire de donnees actuel #
|
||||||
|
# et rejoue les WAL jusqu'a : #
|
||||||
|
# ${TARGET_TIME}
|
||||||
|
# #
|
||||||
|
# Prerequis : #
|
||||||
|
# - backend arrete (replicas=0) : sinon ecritures perdues #
|
||||||
|
# - un dump logique frais pris AVANT (voir plus bas) #
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
BANNER
|
||||||
|
confirm "Confirmez-vous la destruction de ${PGDATA_HOST} ?" "DETRUIRE ET RESTAURER"
|
||||||
|
|
||||||
|
log "Sauvegarde de securite du repertoire courant (au cas ou)"
|
||||||
|
SAFETY="/var/lib/xpeditis/pgdata-avant-pitr-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
dc stop postgres
|
||||||
|
mv "${PGDATA_HOST}/pgdata" "$SAFETY" || fail "impossible de deplacer le repertoire courant"
|
||||||
|
mkdir -p "${PGDATA_HOST}/pgdata"
|
||||||
|
chown 999:999 "${PGDATA_HOST}/pgdata"
|
||||||
|
chmod 700 "${PGDATA_HOST}/pgdata"
|
||||||
|
log "Ancien repertoire conserve dans ${SAFETY} (a supprimer une fois la restauration validee)"
|
||||||
|
|
||||||
|
log "wal-g backup-fetch (derniere sauvegarde complete)"
|
||||||
|
dc run --rm --no-deps -u postgres postgres \
|
||||||
|
wal-g backup-fetch /var/lib/postgresql/data/pgdata LATEST \
|
||||||
|
|| fail "backup-fetch a echoue"
|
||||||
|
|
||||||
|
log "Configuration du rejeu des WAL jusqu'a ${TARGET_TIME}"
|
||||||
|
dc run --rm --no-deps -u postgres postgres bash -c "
|
||||||
|
set -e
|
||||||
|
D=/var/lib/postgresql/data/pgdata
|
||||||
|
touch \$D/recovery.signal
|
||||||
|
cat >> \$D/postgresql.auto.conf <<CONF
|
||||||
|
restore_command = 'wal-g wal-fetch \"%f\" \"%p\"'
|
||||||
|
recovery_target_time = '${TARGET_TIME}'
|
||||||
|
recovery_target_action = 'promote'
|
||||||
|
recovery_target_timeline = 'latest'
|
||||||
|
CONF
|
||||||
|
" || fail "configuration de la restauration echouee"
|
||||||
|
|
||||||
|
log "Redemarrage de PostgreSQL : le rejeu des WAL commence"
|
||||||
|
dc up -d postgres
|
||||||
|
|
||||||
|
log "Suivez la progression : docker compose logs -f postgres"
|
||||||
|
log "La base sort du mode restauration quand 'database system is ready to accept connections' apparait."
|
||||||
|
log "Verifiez ENSUITE les donnees avant de relancer le backend."
|
||||||
|
;;
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
*)
|
||||||
|
cat <<USAGE
|
||||||
|
Usage:
|
||||||
|
$0 verify [fichier.dump.age] Test de restauration sans impact
|
||||||
|
$0 logical <fichier.dump.age> [base] Restauration logique dans une base a part
|
||||||
|
$0 pitr "YYYY-MM-DD HH:MM:SS" Restauration a un instant T (DESTRUCTIF)
|
||||||
|
|
||||||
|
Lister les sauvegardes disponibles :
|
||||||
|
ls -lht /var/lib/xpeditis/dumps/
|
||||||
|
docker compose -f ${COMPOSE_DIR}/docker-compose.data.yml exec -u postgres postgres wal-g backup-list --detail
|
||||||
|
USAGE
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
26
infra/prod/data-node/backup/xpeditis-backup-verify.service
Normal file
26
infra/prod/data-node/backup/xpeditis-backup-verify.service
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Verification de restaurabilite des sauvegardes Xpeditis
|
||||||
|
Documentation=file:///opt/xpeditis/data-node/backup/pg-restore.sh
|
||||||
|
After=docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Environment=COMPOSE_DIR=/opt/xpeditis/data-node
|
||||||
|
Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data
|
||||||
|
# `verify` restaure le dernier dump dans une base jetable puis la supprime.
|
||||||
|
# Une sauvegarde jamais restauree n'est pas une sauvegarde.
|
||||||
|
ExecStart=/opt/xpeditis/data-node/backup/pg-restore.sh verify
|
||||||
|
TimeoutStartSec=2h
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=xpeditis-backup-verify
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
12
infra/prod/data-node/backup/xpeditis-backup-verify.timer
Normal file
12
infra/prod/data-node/backup/xpeditis-backup-verify.timer
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Verification hebdomadaire des sauvegardes Xpeditis
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Dimanche 04h00, apres la sauvegarde de la nuit.
|
||||||
|
OnCalendar=Sun *-*-* 04:00:00
|
||||||
|
RandomizedDelaySec=600
|
||||||
|
Persistent=true
|
||||||
|
Unit=xpeditis-backup-verify.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
29
infra/prod/data-node/backup/xpeditis-backup.service
Normal file
29
infra/prod/data-node/backup/xpeditis-backup.service
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Sauvegarde PostgreSQL Xpeditis (WAL-G + dump logique chiffre)
|
||||||
|
Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh
|
||||||
|
After=docker.service network-online.target
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=root
|
||||||
|
Environment=COMPOSE_DIR=/opt/xpeditis/data-node
|
||||||
|
Environment=ENV_FILE=/opt/xpeditis/data-node/.env.data
|
||||||
|
ExecStart=/opt/xpeditis/data-node/backup/pg-backup.sh
|
||||||
|
TimeoutStartSec=3h
|
||||||
|
|
||||||
|
# Le script ecrit dans /var/lib/xpeditis/dumps et pilote Docker : on ne peut pas
|
||||||
|
# aller beaucoup plus loin dans le confinement sans le casser.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=xpeditis-backup
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
16
infra/prod/data-node/backup/xpeditis-backup.timer
Normal file
16
infra/prod/data-node/backup/xpeditis-backup.timer
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Sauvegarde PostgreSQL Xpeditis - quotidienne
|
||||||
|
Documentation=file:///opt/xpeditis/data-node/backup/pg-backup.sh
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# 02h30 heure de Paris : creux d'activite des transitaires europeens, et avant
|
||||||
|
# la fenetre de redemarrage automatique des mises a jour de securite (04h30).
|
||||||
|
OnCalendar=*-*-* 02:30:00
|
||||||
|
# Etale le declenchement pour ne pas taper l'Object Storage a la seconde pres.
|
||||||
|
RandomizedDelaySec=300
|
||||||
|
# Rattrape la sauvegarde si le serveur etait eteint a l'heure prevue.
|
||||||
|
Persistent=true
|
||||||
|
Unit=xpeditis-backup.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
30
infra/prod/data-node/conf/pg_hba.conf
Normal file
30
infra/prod/data-node/conf/pg_hba.conf
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# pg_hba.conf - production Xpeditis
|
||||||
|
# =============================================================================
|
||||||
|
# Principe : `hostssl` uniquement. Aucune connexion reseau en clair n'est
|
||||||
|
# possible, meme depuis le reseau prive Hetzner.
|
||||||
|
#
|
||||||
|
# Consequence a connaitre : toute application se connectant a cette base DOIT
|
||||||
|
# negocier TLS. Cote Xpeditis cela signifie DATABASE_SSL=true, honore par
|
||||||
|
# data-source.ts, par le pool applicatif et par scripts/setup/startup.js.
|
||||||
|
#
|
||||||
|
# TYPE DATABASE USER ADDRESS METHOD
|
||||||
|
|
||||||
|
# --- Socket local (administration dans le conteneur, wal-g, pg_dump) ---------
|
||||||
|
local all postgres peer
|
||||||
|
local all all scram-sha-256
|
||||||
|
|
||||||
|
# --- Noeud applicatif (k3s) --------------------------------------------------
|
||||||
|
hostssl all all 10.10.1.10/32 scram-sha-256
|
||||||
|
|
||||||
|
# --- Reseau interne du compose (postgres-exporter) ---------------------------
|
||||||
|
hostssl all all 172.28.0.0/24 scram-sha-256
|
||||||
|
|
||||||
|
# --- Replication (standby futur, phase 2 du plan de charge) ------------------
|
||||||
|
hostssl replication all 10.10.1.0/24 scram-sha-256
|
||||||
|
|
||||||
|
# --- Tout le reste est refuse explicitement ---------------------------------
|
||||||
|
# `reject` plutot qu'une absence de regle : le refus est immediat et journalise,
|
||||||
|
# ce qui rend les tentatives visibles dans Loki.
|
||||||
|
host all all 0.0.0.0/0 reject
|
||||||
|
host all all ::/0 reject
|
||||||
108
infra/prod/data-node/conf/postgresql.conf
Normal file
108
infra/prod/data-node/conf/postgresql.conf
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# PostgreSQL 15 - production Xpeditis
|
||||||
|
# Cible : Hetzner CPX31 (4 vCPU / 8 Go), partage avec Redis (1,5 Go plafonne).
|
||||||
|
# Budget memoire retenu pour PostgreSQL : ~6 Go.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Connexions --------------------------------------------------------------
|
||||||
|
listen_addresses = '*' # le confinement reseau est assure par le bind
|
||||||
|
# Docker sur l'IP privee + UFW + pg_hba
|
||||||
|
port = 5432
|
||||||
|
max_connections = 150 # 2 replicas backend x pool TypeORM + marge ops
|
||||||
|
superuser_reserved_connections = 5
|
||||||
|
|
||||||
|
# --- TLS ---------------------------------------------------------------------
|
||||||
|
# Obligatoire : pg_hba n'accepte que des lignes `hostssl`. Le reseau prive
|
||||||
|
# Hetzner n'est pas chiffre par l'hyperviseur, c'est nous qui le chiffrons.
|
||||||
|
ssl = on
|
||||||
|
ssl_cert_file = '/var/lib/xpeditis/certs/server.crt'
|
||||||
|
ssl_key_file = '/var/lib/xpeditis/certs/server.key'
|
||||||
|
ssl_min_protocol_version = 'TLSv1.2'
|
||||||
|
ssl_prefer_server_ciphers = on
|
||||||
|
|
||||||
|
# --- Authentification --------------------------------------------------------
|
||||||
|
password_encryption = scram-sha-256
|
||||||
|
|
||||||
|
# --- Memoire -----------------------------------------------------------------
|
||||||
|
shared_buffers = 2GB
|
||||||
|
effective_cache_size = 5GB
|
||||||
|
maintenance_work_mem = 512MB
|
||||||
|
work_mem = 16MB # par operation de tri/hash, pas par connexion
|
||||||
|
huge_pages = try
|
||||||
|
|
||||||
|
# --- Disque (SSD NVMe Hetzner) ----------------------------------------------
|
||||||
|
random_page_cost = 1.1
|
||||||
|
effective_io_concurrency = 200
|
||||||
|
max_worker_processes = 4
|
||||||
|
max_parallel_workers = 4
|
||||||
|
max_parallel_workers_per_gather = 2
|
||||||
|
max_parallel_maintenance_workers = 2
|
||||||
|
|
||||||
|
# --- WAL / archivage ---------------------------------------------------------
|
||||||
|
wal_level = replica # suffisant pour PITR + futur standby physique
|
||||||
|
wal_compression = on
|
||||||
|
max_wal_size = 4GB
|
||||||
|
min_wal_size = 1GB
|
||||||
|
checkpoint_completion_target = 0.9
|
||||||
|
checkpoint_timeout = 15min
|
||||||
|
|
||||||
|
archive_mode = on
|
||||||
|
archive_command = 'wal-g wal-push %p'
|
||||||
|
# Force la cloture d'un segment WAL toutes les 5 min meme sans activite :
|
||||||
|
# c'est ce qui plafonne le RPO a 5 minutes de donnees perdues au maximum.
|
||||||
|
archive_timeout = 300
|
||||||
|
|
||||||
|
# Conserve de quoi rattacher un standby sans repartir d'un basebackup.
|
||||||
|
max_wal_senders = 5
|
||||||
|
wal_keep_size = 1GB
|
||||||
|
max_replication_slots = 5
|
||||||
|
|
||||||
|
# --- Autovacuum --------------------------------------------------------------
|
||||||
|
# audit_logs et csv_rates subissent beaucoup d'ecritures : on vacuume plus tot
|
||||||
|
# que les valeurs par defaut, sinon les index gonflent et les recherches
|
||||||
|
# tarifaires ralentissent.
|
||||||
|
autovacuum = on
|
||||||
|
autovacuum_max_workers = 3
|
||||||
|
autovacuum_naptime = 30s
|
||||||
|
autovacuum_vacuum_scale_factor = 0.05
|
||||||
|
autovacuum_analyze_scale_factor = 0.02
|
||||||
|
autovacuum_vacuum_cost_limit = 1000
|
||||||
|
|
||||||
|
# --- Delais de garde ---------------------------------------------------------
|
||||||
|
statement_timeout = 300000 # 5 min : borne les requetes folles
|
||||||
|
# tout en laissant passer les
|
||||||
|
# imports CSV de grilles
|
||||||
|
idle_in_transaction_session_timeout = 300000 # 5 min : libere les verrous oublies
|
||||||
|
lock_timeout = 30000
|
||||||
|
tcp_keepalives_idle = 60
|
||||||
|
tcp_keepalives_interval = 10
|
||||||
|
tcp_keepalives_count = 6
|
||||||
|
|
||||||
|
# --- Observabilite -----------------------------------------------------------
|
||||||
|
shared_preload_libraries = 'pg_stat_statements'
|
||||||
|
pg_stat_statements.max = 5000
|
||||||
|
pg_stat_statements.track = top
|
||||||
|
track_io_timing = on
|
||||||
|
track_functions = pl
|
||||||
|
|
||||||
|
logging_collector = off # les logs partent sur stdout -> Docker -> Loki
|
||||||
|
log_destination = 'stderr'
|
||||||
|
log_min_duration_statement = 500 # trace toute requete > 500 ms
|
||||||
|
log_line_prefix = '%m [%p] %q%u@%d %a '
|
||||||
|
log_checkpoints = on
|
||||||
|
log_connections = on
|
||||||
|
log_disconnections = on
|
||||||
|
log_lock_waits = on
|
||||||
|
log_temp_files = 0
|
||||||
|
log_autovacuum_min_duration = 1000
|
||||||
|
log_statement = 'ddl' # trace les changements de schema (migrations)
|
||||||
|
log_timezone = 'Europe/Paris'
|
||||||
|
|
||||||
|
# --- Localisation ------------------------------------------------------------
|
||||||
|
timezone = 'Europe/Paris'
|
||||||
|
datestyle = 'iso, mdy'
|
||||||
|
lc_messages = 'C'
|
||||||
|
lc_monetary = 'C'
|
||||||
|
lc_numeric = 'C'
|
||||||
|
lc_time = 'C'
|
||||||
|
default_text_search_config = 'pg_catalog.french'
|
||||||
85
infra/prod/data-node/conf/redis.conf
Normal file
85
infra/prod/data-node/conf/redis.conf
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Redis 7 - production Xpeditis
|
||||||
|
# =============================================================================
|
||||||
|
# Le mot de passe n'est PAS ici : il est injecte par --requirepass depuis
|
||||||
|
# .env.data (cf. docker-compose.data.yml). Ce fichier est versionne, pas lui.
|
||||||
|
|
||||||
|
# --- Reseau ------------------------------------------------------------------
|
||||||
|
# Ecoute sur toutes les interfaces DU CONTENEUR ; l'exposition reelle est
|
||||||
|
# limitee par Docker (bind sur l'IP privee) puis par UFW.
|
||||||
|
bind 0.0.0.0
|
||||||
|
protected-mode yes
|
||||||
|
port 6379
|
||||||
|
tcp-backlog 511
|
||||||
|
tcp-keepalive 300
|
||||||
|
timeout 300
|
||||||
|
|
||||||
|
# --- Persistance -------------------------------------------------------------
|
||||||
|
# Redis ne sert pas qu'a cacher les cotations : il porte aussi des donnees de
|
||||||
|
# session et de revocation de jetons. Un redemarrage ne doit pas les perdre,
|
||||||
|
# sinon des jetons revoques redeviendraient valides.
|
||||||
|
dir /data
|
||||||
|
appendonly yes
|
||||||
|
appendfilename "appendonly.aof"
|
||||||
|
appendfsync everysec
|
||||||
|
no-appendfsync-on-rewrite no
|
||||||
|
auto-aof-rewrite-percentage 100
|
||||||
|
auto-aof-rewrite-min-size 64mb
|
||||||
|
aof-use-rdb-preamble yes
|
||||||
|
|
||||||
|
save 900 1
|
||||||
|
save 300 10
|
||||||
|
save 60 10000
|
||||||
|
dbfilename dump.rdb
|
||||||
|
rdbcompression yes
|
||||||
|
rdbchecksum yes
|
||||||
|
stop-writes-on-bgsave-error yes
|
||||||
|
|
||||||
|
# --- Memoire -----------------------------------------------------------------
|
||||||
|
# Estimation Excel : 0,5 Go a 100 utilisateurs. On plafonne a 1 Go, soit 2x de
|
||||||
|
# marge, sous la limite conteneur de 1,5 Go.
|
||||||
|
#
|
||||||
|
# volatile-lru et non allkeys-lru : seules les cles portant un TTL peuvent etre
|
||||||
|
# evincees. Les cotations tarifaires (TTL 15 min) sont donc sacrifiables, ce qui
|
||||||
|
# est le comportement voulu. Surveiller quand meme used_memory : une eviction
|
||||||
|
# subie sur une cle de revocation prolongerait la validite d'un jeton revoque.
|
||||||
|
# Alerte Grafana a 75 % (cf. k8s/monitoring/).
|
||||||
|
maxmemory 1gb
|
||||||
|
maxmemory-policy volatile-lru
|
||||||
|
maxmemory-samples 5
|
||||||
|
|
||||||
|
lazyfree-lazy-eviction yes
|
||||||
|
lazyfree-lazy-expire yes
|
||||||
|
lazyfree-lazy-server-del yes
|
||||||
|
|
||||||
|
# --- Durcissement ------------------------------------------------------------
|
||||||
|
# Les commandes destructrices ou de reconnaissance sont RENOMMEES, pas
|
||||||
|
# supprimees : une injection cote application ne peut plus les atteindre, mais
|
||||||
|
# un operateur qui connait le nom garde une porte de sortie en incident.
|
||||||
|
#
|
||||||
|
# Consequence connue : RedisCacheAdapter.clear() (redis-cache.adapter.ts:133)
|
||||||
|
# appelle FLUSHDB et echouera donc en production. C'est volontaire -- cette
|
||||||
|
# methode n'a aucun appelant dans le code applicatif et vider le cache de prod
|
||||||
|
# depuis une requete HTTP ne doit jamais etre possible. Pour vider le cache
|
||||||
|
# manuellement, utiliser le nom renomme ci-dessous depuis db-01.
|
||||||
|
#
|
||||||
|
# Ces noms sont publics dans Git : ils protegent d'une injection, pas d'un
|
||||||
|
# attaquant ayant deja le mot de passe Redis ET l'acces reseau. Si vous voulez
|
||||||
|
# une vraie defense, remplacez-les par des chaines aleatoires et notez-les dans
|
||||||
|
# le coffre-fort.
|
||||||
|
rename-command FLUSHALL XPD_FLUSHALL_9c1f
|
||||||
|
rename-command FLUSHDB XPD_FLUSHDB_9c1f
|
||||||
|
rename-command KEYS XPD_KEYS_9c1f
|
||||||
|
rename-command CONFIG XPD_CONFIG_9c1f
|
||||||
|
rename-command DEBUG ""
|
||||||
|
rename-command MODULE ""
|
||||||
|
|
||||||
|
# --- Journalisation ----------------------------------------------------------
|
||||||
|
logfile ""
|
||||||
|
loglevel notice
|
||||||
|
|
||||||
|
# --- Divers ------------------------------------------------------------------
|
||||||
|
databases 16
|
||||||
|
slowlog-log-slower-than 10000
|
||||||
|
slowlog-max-len 128
|
||||||
|
latency-monitor-threshold 100
|
||||||
187
infra/prod/data-node/docker-compose.data.yml
Normal file
187
infra/prod/data-node/docker-compose.data.yml
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Noeud de donnees Xpeditis - production
|
||||||
|
# =============================================================================
|
||||||
|
# Deploye sur db-01 uniquement, dans /opt/xpeditis/data-node.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.data.yml --env-file .env.data up -d
|
||||||
|
#
|
||||||
|
# Regles non negociables appliquees ici :
|
||||||
|
# - Aucun port publie sur 0.0.0.0 : bind explicite sur l'IP privee.
|
||||||
|
# - Aucun mot de passe en dur : tout vient de .env.data (chiffre SOPS en Git).
|
||||||
|
# - PostgreSQL exige TLS (hostssl) pour toute connexion venant du reseau.
|
||||||
|
# - Les conteneurs ne peuvent pas escalader leurs privileges.
|
||||||
|
|
||||||
|
name: xpeditis-data
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.postgres
|
||||||
|
image: xpeditis/postgres-walg:15
|
||||||
|
container_name: xpeditis-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 60s
|
||||||
|
|
||||||
|
# Bind sur l'IP privee : injoignable depuis Internet, meme si UFW tombe.
|
||||||
|
ports:
|
||||||
|
- "${PRIVATE_IP}:5432:5432"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: "${POSTGRES_DB}"
|
||||||
|
POSTGRES_USER: "${POSTGRES_USER}"
|
||||||
|
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
# scram-sha-256 pour tous les mots de passe (jamais md5).
|
||||||
|
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256 --data-checksums"
|
||||||
|
TZ: Europe/Paris
|
||||||
|
|
||||||
|
# --- WAL-G : archivage continu vers Hetzner Object Storage --------------
|
||||||
|
WALG_S3_PREFIX: "${WALG_S3_PREFIX}"
|
||||||
|
AWS_ACCESS_KEY_ID: "${WALG_ACCESS_KEY_ID}"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "${WALG_SECRET_ACCESS_KEY}"
|
||||||
|
AWS_ENDPOINT: "${WALG_S3_ENDPOINT}"
|
||||||
|
AWS_REGION: "${WALG_S3_REGION}"
|
||||||
|
AWS_S3_FORCE_PATH_STYLE: "true"
|
||||||
|
# Chiffrement cote client : meme si le bucket fuite, les sauvegardes
|
||||||
|
# restent illisibles sans la cle privee libsodium.
|
||||||
|
WALG_LIBSODIUM_KEY: "${WALG_LIBSODIUM_KEY}"
|
||||||
|
WALG_COMPRESSION_METHOD: brotli
|
||||||
|
WALG_DELTA_MAX_STEPS: "6"
|
||||||
|
WALG_UPLOAD_CONCURRENCY: "2"
|
||||||
|
PGHOST: /var/run/postgresql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- /var/lib/xpeditis/pgdata:/var/lib/postgresql/data
|
||||||
|
- ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||||
|
- ./conf/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
|
||||||
|
- /var/lib/xpeditis/certs:/var/lib/xpeditis/certs:ro
|
||||||
|
- ./backup:/opt/backup:ro
|
||||||
|
- /var/lib/xpeditis/dumps:/var/lib/xpeditis/dumps
|
||||||
|
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- config_file=/etc/postgresql/postgresql.conf
|
||||||
|
- -c
|
||||||
|
- hba_file=/etc/postgresql/pg_hba.conf
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h /var/run/postgresql"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
shm_size: 512mb
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "3.0"
|
||||||
|
memory: 6g
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
container_name: xpeditis-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${PRIVATE_IP}:6379:6379"
|
||||||
|
|
||||||
|
# requirepass passe en ligne de commande : redis.conf ne sait pas lire
|
||||||
|
# de variable d'environnement, et on refuse d'ecrire le mot de passe
|
||||||
|
# dans un fichier versionne.
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- /usr/local/etc/redis/redis.conf
|
||||||
|
- --requirepass
|
||||||
|
- "${REDIS_PASSWORD}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- ./conf/redis.conf:/usr/local/etc/redis/redis.conf:ro
|
||||||
|
- /var/lib/xpeditis/redis:/data
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" --no-auth-warning ping | grep -q PONG"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 6
|
||||||
|
start_period: 10s
|
||||||
|
environment:
|
||||||
|
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||||
|
TZ: Europe/Paris
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
sysctls:
|
||||||
|
# Evite les pertes de connexion sous charge sur le backlog TCP.
|
||||||
|
net.core.somaxconn: 1024
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "1.0"
|
||||||
|
memory: 1500m
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# Exportateur Prometheus PostgreSQL : alimente les tableaux de bord Grafana
|
||||||
|
# heberges sur app-01 (scrape via le reseau prive).
|
||||||
|
postgres-exporter:
|
||||||
|
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||||
|
container_name: xpeditis-postgres-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "${PRIVATE_IP}:9187:9187"
|
||||||
|
environment:
|
||||||
|
# Connexion par le reseau interne du compose (jamais par l'IP publiee),
|
||||||
|
# en TLS obligatoire comme toute autre connexion reseau.
|
||||||
|
DATA_SOURCE_NAME: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=require"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "0.25"
|
||||||
|
memory: 128m
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "2"
|
||||||
|
|
||||||
|
# Sous-reseau fixe : il est reference explicitement dans pg_hba.conf, il ne doit
|
||||||
|
# donc pas changer d'un `docker compose up` a l'autre.
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.28.0.0/24
|
||||||
69
infra/prod/env/data-node.env.example
vendored
Normal file
69
infra/prod/env/data-node.env.example
vendored
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Noeud de donnees (db-01) - variables d'environnement
|
||||||
|
# =============================================================================
|
||||||
|
# Sur le serveur : /opt/xpeditis/data-node/.env.data (chmod 600, root:root)
|
||||||
|
# Dans Git : infra/prod/data-node/data-node.sops.env (chiffre SOPS)
|
||||||
|
#
|
||||||
|
# sops -e --input-type dotenv --output-type dotenv \
|
||||||
|
# /opt/xpeditis/data-node/.env.data > data-node/data-node.sops.env
|
||||||
|
# sops -d --input-type dotenv --output-type dotenv \
|
||||||
|
# data-node/data-node.sops.env > /opt/xpeditis/data-node/.env.data
|
||||||
|
#
|
||||||
|
# Generer un secret : openssl rand -base64 32 | tr -d '\n/+=' | head -c 40
|
||||||
|
# Ne JAMAIS reutiliser un secret de preprod : ceux presents dans le depot sont
|
||||||
|
# compromis (cf. docs/mise-en-prod/13-securite-durcissement.md, "Rotation
|
||||||
|
# obligatoire avant la mise en production").
|
||||||
|
|
||||||
|
# --- Reseau ------------------------------------------------------------------
|
||||||
|
# IP privee de db-01 : seule interface sur laquelle Postgres et Redis sont
|
||||||
|
# publies. Doit correspondre a var.db_private_ip cote Terraform.
|
||||||
|
PRIVATE_IP=10.10.1.20
|
||||||
|
|
||||||
|
# --- PostgreSQL --------------------------------------------------------------
|
||||||
|
POSTGRES_DB=xpeditis_prod
|
||||||
|
POSTGRES_USER=xpeditis
|
||||||
|
POSTGRES_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES
|
||||||
|
|
||||||
|
# --- Redis -------------------------------------------------------------------
|
||||||
|
REDIS_PASSWORD=REMPLACER_40_CARACTERES_ALEATOIRES
|
||||||
|
|
||||||
|
# --- WAL-G : archivage continu vers Hetzner Object Storage -------------------
|
||||||
|
# Bucket DEDIE aux sauvegardes, distinct du bucket des documents applicatifs,
|
||||||
|
# avec son propre jeu de cles S3. Si les cles applicatives fuitent, les
|
||||||
|
# sauvegardes restent hors de portee.
|
||||||
|
WALG_S3_PREFIX=s3://xpeditis-prod-pgbackup
|
||||||
|
WALG_S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||||
|
WALG_S3_REGION=fsn1
|
||||||
|
WALG_ACCESS_KEY_ID=REMPLACER
|
||||||
|
WALG_SECRET_ACCESS_KEY=REMPLACER
|
||||||
|
|
||||||
|
# Chiffrement cote client des sauvegardes physiques.
|
||||||
|
# WAL-G attend une cle de 32 octets encodee en HEXADECIMAL (64 caracteres) :
|
||||||
|
# openssl rand -hex 32
|
||||||
|
# Sans cette cle, aucune restauration n'est possible : conservez-la dans le
|
||||||
|
# coffre-fort ET hors ligne.
|
||||||
|
WALG_LIBSODIUM_KEY=REMPLACER_64_CARACTERES_HEXADECIMAUX
|
||||||
|
|
||||||
|
# --- Chiffrement des dumps logiques (age) ------------------------------------
|
||||||
|
# Cle publique age destinataire des dumps quotidiens.
|
||||||
|
# age-keygen -o backup-age.key && grep 'public key' backup-age.key
|
||||||
|
BACKUP_AGE_RECIPIENT=age1REMPLACER
|
||||||
|
# Chemin de la cle PRIVEE sur db-01, lue par pg-restore.sh.
|
||||||
|
BACKUP_AGE_KEY_FILE=/root/.config/xpeditis/backup-age.key
|
||||||
|
|
||||||
|
# --- Storage Box Hetzner (seconde copie des dumps) ---------------------------
|
||||||
|
# Souscrite separement (BX11 ~4 EUR/mois). Cf. 12-sauvegardes-restauration.md.
|
||||||
|
STORAGEBOX_HOST=uXXXXXX.your-storagebox.de
|
||||||
|
STORAGEBOX_USER=uXXXXXX
|
||||||
|
STORAGEBOX_PORT=23
|
||||||
|
STORAGEBOX_SSH_KEY=/root/.ssh/storagebox
|
||||||
|
|
||||||
|
# --- Alertes -----------------------------------------------------------------
|
||||||
|
# Meme webhook que la CI/CD : une sauvegarde en echec doit se voir.
|
||||||
|
DISCORD_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Battement de coeur externe appele a chaque sauvegarde reussie
|
||||||
|
# (BetterStack Heartbeats ou healthchecks.io, gratuit). Le service alerte quand
|
||||||
|
# l'appel n'arrive PAS -- c'est ce qui detecte un serveur eteint ou un timer
|
||||||
|
# systemd desactive, ce qu'une supervision interne ne verrait jamais.
|
||||||
|
BACKUP_HEARTBEAT_URL=
|
||||||
69
infra/prod/env/github-secrets.md
vendored
Normal file
69
infra/prod/env/github-secrets.md
vendored
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Secrets et variables GitHub Actions — production
|
||||||
|
|
||||||
|
Dépôt → **Settings → Environments → `production`**.
|
||||||
|
|
||||||
|
Utiliser un *Environment* et non des secrets de dépôt permet d'exiger une
|
||||||
|
approbation manuelle avant tout déploiement, et empêche une branche non
|
||||||
|
protégée d'accéder à ces valeurs.
|
||||||
|
|
||||||
|
> **Activez « Required reviewers » sur l'environnement `production`.**
|
||||||
|
> Sans cela, n'importe quel `push` sur `main` déploie sans validation humaine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
| Nom | Contenu | Comment l'obtenir |
|
||||||
|
|---|---|---|
|
||||||
|
| `REGISTRY_TOKEN` | Jeton du registre Scaleway (lecture + écriture) | Console Scaleway → Container Registry → jetons. Déjà utilisé par la preprod. |
|
||||||
|
| `HCLOUD_TOKEN_CICD` | Jeton API Hetzner Cloud | Console Hetzner → Security → API tokens. **Read & Write** (nécessaire pour ouvrir/fermer le firewall CI). Jeton **distinct** de celui de Terraform, pour pouvoir le révoquer seul. |
|
||||||
|
| `PROD_SSH_HOST` | IP publique de app-01 | `make tf-output` |
|
||||||
|
| `PROD_SSH_USER` | `deploy` | |
|
||||||
|
| `PROD_SSH_KEY` | Clé privée SSH **dédiée à la CI** | `ssh-keygen -t ed25519 -a 100 -C "github-actions-prod" -f ci_deploy` puis ajouter la clé publique dans `~deploy/.ssh/authorized_keys` sur app-01. Ne réutilisez pas votre clé personnelle. |
|
||||||
|
| `PROD_SSH_KNOWN_HOSTS` | Empreinte du serveur | `ssh-keyscan -H <IP_APP_01>` — évite qu'un détournement DNS/BGP redirige le déploiement vers une machine tierce. |
|
||||||
|
| `NEXT_PUBLIC_API_URL_PROD` | `https://api.xpeditis.com` | Figé dans l'image frontend au build. |
|
||||||
|
| `NEXT_PUBLIC_APP_URL_PROD` | `https://app.xpeditis.com` | idem |
|
||||||
|
| `DISCORD_WEBHOOK_URL` | Webhook du canal de déploiement | Déjà utilisé par la preprod. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables (non sensibles)
|
||||||
|
|
||||||
|
| Nom | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| `PROD_API_URL` | `https://api.xpeditis.com` |
|
||||||
|
| `PROD_APP_URL` | `https://app.xpeditis.com` |
|
||||||
|
| `HCLOUD_CICD_FIREWALL` | `xpeditis-prod-fw-cicd` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Restreindre la clé SSH de la CI
|
||||||
|
|
||||||
|
La clé de déploiement n'a besoin que de lancer un script. Limitez-la dans
|
||||||
|
`~deploy/.ssh/authorized_keys` sur app-01 :
|
||||||
|
|
||||||
|
```
|
||||||
|
restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
|
||||||
|
```
|
||||||
|
|
||||||
|
`restrict` désactive le transfert de ports, d'agent et X11. Le `command=` force
|
||||||
|
l'exécution du wrapper quelle que soit la commande demandée : une clé volée ne
|
||||||
|
donne pas un shell.
|
||||||
|
|
||||||
|
> `rsync` a besoin d'exécuter sa propre commande. Le wrapper l'autorise
|
||||||
|
> explicitement en inspectant `SSH_ORIGINAL_COMMAND` — voir
|
||||||
|
> `scripts/ssh-deploy-wrapper.sh`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui ne doit PAS être un secret GitHub
|
||||||
|
|
||||||
|
- **La clé privée age / SOPS.** Elle déchiffre *tous* les secrets de production.
|
||||||
|
Les secrets sont appliqués depuis votre poste (`make secrets-apply`), pas
|
||||||
|
depuis la CI. Un dépôt compromis ne doit pas donner les mots de passe de la
|
||||||
|
base.
|
||||||
|
- **Le `kubeconfig`.** L'API Kubernetes n'est ouverte qu'à vos IP
|
||||||
|
d'administration ; la CI passe par SSH et utilise le kubeconfig local du
|
||||||
|
serveur.
|
||||||
|
- **Le token Terraform.** Il peut détruire l'infrastructure. Il reste sur votre
|
||||||
|
poste.
|
||||||
30
infra/prod/k8s/base/00-namespaces.yaml
Normal file
30
infra/prod/k8s/base/00-namespaces.yaml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
environment: production
|
||||||
|
# Pod Security Admission en mode "restricted" : refuse tout pod privilegie,
|
||||||
|
# tout conteneur root, toute capability. C'est une barriere du cluster,
|
||||||
|
# independante de ce que contiennent les manifests applicatifs.
|
||||||
|
pod-security.kubernetes.io/enforce: restricted
|
||||||
|
pod-security.kubernetes.io/enforce-version: latest
|
||||||
|
pod-security.kubernetes.io/audit: restricted
|
||||||
|
pod-security.kubernetes.io/warn: restricted
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
environment: production
|
||||||
|
# "baseline" et non "restricted" : Promtail doit lire les journaux des
|
||||||
|
# conteneurs de l'hote (hostPath), ce que "restricted" interdit.
|
||||||
|
# Le compromis est assume et confine a ce seul namespace.
|
||||||
|
pod-security.kubernetes.io/enforce: baseline
|
||||||
|
pod-security.kubernetes.io/enforce-version: latest
|
||||||
|
pod-security.kubernetes.io/audit: restricted
|
||||||
|
pod-security.kubernetes.io/warn: restricted
|
||||||
59
infra/prod/k8s/base/01-limits.yaml
Normal file
59
infra/prod/k8s/base/01-limits.yaml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Garde-fous de consommation
|
||||||
|
# =============================================================================
|
||||||
|
# Le noeud applicatif est un CPX41 (8 vCPU / 16 Go) partage entre l'application,
|
||||||
|
# l'ingress et la supervision. Sans quota, une fuite memoire du backend fait
|
||||||
|
# tomber Traefik et Grafana avec lui : plus d'application ET plus de moyen de
|
||||||
|
# comprendre pourquoi.
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ResourceQuota
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-prod-quota
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
hard:
|
||||||
|
requests.cpu: "3"
|
||||||
|
requests.memory: 5Gi
|
||||||
|
limits.cpu: "7"
|
||||||
|
limits.memory: 9Gi
|
||||||
|
pods: "20"
|
||||||
|
persistentvolumeclaims: "4"
|
||||||
|
services.loadbalancers: "0" # aucun LB supplementaire facture par erreur
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: LimitRange
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-prod-defaults
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
limits:
|
||||||
|
- type: Container
|
||||||
|
# Un conteneur deploye sans requests/limits (debug, job ponctuel) herite
|
||||||
|
# de valeurs raisonnables au lieu de pouvoir tout consommer.
|
||||||
|
default:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
defaultRequest:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
max:
|
||||||
|
cpu: "4"
|
||||||
|
memory: 4Gi
|
||||||
|
min:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 32Mi
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ResourceQuota
|
||||||
|
metadata:
|
||||||
|
name: monitoring-quota
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
hard:
|
||||||
|
requests.cpu: "1"
|
||||||
|
requests.memory: 1536Mi
|
||||||
|
limits.cpu: "3"
|
||||||
|
limits.memory: 3Gi
|
||||||
|
pods: "10"
|
||||||
99
infra/prod/k8s/base/02-configmap-backend.yaml
Normal file
99
infra/prod/k8s/base/02-configmap-backend.yaml
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Configuration NON SECRETE du backend
|
||||||
|
# =============================================================================
|
||||||
|
# Seules figurent ici les variables reellement lues par le code
|
||||||
|
# (verifie dans app.module.ts, security.config.ts et les adaptateurs).
|
||||||
|
# Les identifiants et cles vivent dans le Secret xpeditis-backend-secrets.
|
||||||
|
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-backend-config
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
data:
|
||||||
|
NODE_ENV: "production"
|
||||||
|
PORT: "4000"
|
||||||
|
API_PREFIX: "api/v1"
|
||||||
|
# Force la sortie pino en JSON : c'est ce que Promtail sait decouper en
|
||||||
|
# champs (level, context, reqId) pour Loki.
|
||||||
|
LOG_FORMAT: "json"
|
||||||
|
|
||||||
|
# --- URLs publiques --------------------------------------------------------
|
||||||
|
APP_URL: "https://app.xpeditis.com"
|
||||||
|
FRONTEND_URL: "https://app.xpeditis.com"
|
||||||
|
BACKEND_URL: "https://api.xpeditis.com"
|
||||||
|
|
||||||
|
# Liste blanche CORS. `credentials: true` interdit le joker `*` : chaque
|
||||||
|
# origine autorisee doit etre enumeree (security.config.ts).
|
||||||
|
CORS_ORIGIN: "https://app.xpeditis.com,https://www.xpeditis.com,https://xpeditis.com"
|
||||||
|
|
||||||
|
# --- Cookies d'authentification -------------------------------------------
|
||||||
|
# Le point initial rend le cookie valide pour tous les sous-domaines : l'API
|
||||||
|
# (api.xpeditis.com) le pose, le middleware Next.js (app.xpeditis.com) le lit.
|
||||||
|
# Sans cela, la connexion reussit mais l'utilisateur est renvoye sur /login.
|
||||||
|
COOKIE_DOMAIN: ".xpeditis.com"
|
||||||
|
# app et api partagent le meme site : 'lax' suffit et reste le plus sur.
|
||||||
|
# Ne passer a 'none' que si le front devient reellement cross-site (et 'none'
|
||||||
|
# impose Secure).
|
||||||
|
COOKIE_SAMESITE: "lax"
|
||||||
|
|
||||||
|
# --- PostgreSQL (db-01, reseau prive) --------------------------------------
|
||||||
|
DATABASE_HOST: "10.10.1.20"
|
||||||
|
DATABASE_PORT: "5432"
|
||||||
|
DATABASE_NAME: "xpeditis_prod"
|
||||||
|
# pg_hba n'accepte que des lignes `hostssl` : sans cette valeur a "true",
|
||||||
|
# le backend ne peut tout simplement pas se connecter.
|
||||||
|
DATABASE_SSL: "true"
|
||||||
|
DATABASE_LOGGING: "false"
|
||||||
|
|
||||||
|
# --- Redis (db-01, reseau prive) -------------------------------------------
|
||||||
|
REDIS_HOST: "10.10.1.20"
|
||||||
|
REDIS_PORT: "6379"
|
||||||
|
REDIS_DB: "0"
|
||||||
|
|
||||||
|
# --- JWT -------------------------------------------------------------------
|
||||||
|
JWT_ACCESS_EXPIRATION: "15m"
|
||||||
|
JWT_REFRESH_EXPIRATION: "7d"
|
||||||
|
|
||||||
|
# --- SMTP (Brevo) ----------------------------------------------------------
|
||||||
|
SMTP_HOST: "smtp-relay.brevo.com"
|
||||||
|
SMTP_PORT: "587"
|
||||||
|
SMTP_SECURE: "false"
|
||||||
|
SMTP_FROM: "noreply@xpeditis.com"
|
||||||
|
|
||||||
|
# --- Stockage objet (Hetzner Object Storage, compatible S3) ----------------
|
||||||
|
# Aucun changement de code : le SDK AWS parle a Hetzner via AWS_S3_ENDPOINT.
|
||||||
|
AWS_REGION: "fsn1"
|
||||||
|
AWS_S3_ENDPOINT: "https://fsn1.your-objectstorage.com"
|
||||||
|
AWS_S3_BUCKET: "xpeditis-prod-documents"
|
||||||
|
|
||||||
|
# --- Collecteur de logs applicatifs ---------------------------------------
|
||||||
|
LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200"
|
||||||
|
|
||||||
|
# --- Premier administrateur ------------------------------------------------
|
||||||
|
# Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le
|
||||||
|
# compte de demonstration admin@xpeditis.com / Password123!.
|
||||||
|
#
|
||||||
|
# Renseigne SEUL (sans BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret), il cree
|
||||||
|
# un compte ADMIN actif SANS mot de passe utilisable : vous definissez le
|
||||||
|
# votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part.
|
||||||
|
#
|
||||||
|
# La migration ne fait rien s'il existe deja un administrateur actif : elle ne
|
||||||
|
# peut donc pas en creer un second lors d'un deploiement ulterieur.
|
||||||
|
#
|
||||||
|
# Cette adresse doit etre RELEVABLE : c'est par elle que passe le lien de
|
||||||
|
# definition du mot de passe.
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com"
|
||||||
|
BOOTSTRAP_ADMIN_FIRST_NAME: "Admin"
|
||||||
|
BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis"
|
||||||
|
|
||||||
|
# Organisation de rattachement (users.organization_id est NOT NULL).
|
||||||
|
# Creee si elle n'existe pas ; a completer ensuite dans l'interface.
|
||||||
|
BOOTSTRAP_ADMIN_ORG_NAME: "Xpeditis"
|
||||||
|
BOOTSTRAP_ADMIN_ORG_STREET: "A completer"
|
||||||
|
BOOTSTRAP_ADMIN_ORG_CITY: "A completer"
|
||||||
|
BOOTSTRAP_ADMIN_ORG_POSTAL_CODE: "00000"
|
||||||
|
BOOTSTRAP_ADMIN_ORG_COUNTRY: "FR"
|
||||||
165
infra/prod/k8s/base/03-secrets.template.yaml
Normal file
165
infra/prod/k8s/base/03-secrets.template.yaml
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# GABARIT de secrets -- NE JAMAIS COMMITTER CE FICHIER RENSEIGNE
|
||||||
|
# =============================================================================
|
||||||
|
# Mode d'emploi :
|
||||||
|
#
|
||||||
|
# 1. cp 03-secrets.template.yaml /tmp/secrets.yaml
|
||||||
|
# 2. renseigner chaque REMPLACER (voir 06-secrets-sops.md)
|
||||||
|
# 3. sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
|
||||||
|
# 4. shred -u /tmp/secrets.yaml
|
||||||
|
# 5. git add k8s/base/03-secrets.sops.yaml <-- celui-la est chiffre, il se commit
|
||||||
|
#
|
||||||
|
# Application sur le cluster : bash scripts/secrets-apply.sh
|
||||||
|
#
|
||||||
|
# Generation d'une valeur aleatoire :
|
||||||
|
# openssl rand -base64 48 | tr -d '\n'
|
||||||
|
#
|
||||||
|
# RAPPEL : aucune valeur de preprod ne doit etre reprise. Les secrets presents
|
||||||
|
# dans infra/preprod/docker-stack.preprod.yml sont dans l'historique Git, donc
|
||||||
|
# compromis (cf. 13-securite-durcissement.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-backend-secrets
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
# --- Base de donnees -------------------------------------------------------
|
||||||
|
DATABASE_USER: "xpeditis"
|
||||||
|
DATABASE_PASSWORD: "REMPLACER" # identique a POSTGRES_PASSWORD de db-01
|
||||||
|
|
||||||
|
# --- Redis -----------------------------------------------------------------
|
||||||
|
REDIS_PASSWORD: "REMPLACER" # identique a REDIS_PASSWORD de db-01
|
||||||
|
|
||||||
|
# --- JWT -------------------------------------------------------------------
|
||||||
|
# Minimum 32 caracteres (valide par Joi au demarrage). Le changer invalide
|
||||||
|
# toutes les sessions en cours : a faire en dehors des heures ouvrees.
|
||||||
|
JWT_SECRET: "REMPLACER_64_CARACTERES_MINIMUM"
|
||||||
|
|
||||||
|
# Derive les mots de passe des documents transporteurs. S'il est absent, le
|
||||||
|
# code retombe sur JWT_SECRET -- et une rotation du JWT rendrait alors
|
||||||
|
# illisibles tous les documents deja emis. On le definit donc explicitement,
|
||||||
|
# et on ne le change JAMAIS sans plan de migration.
|
||||||
|
DOCUMENT_PASSWORD_SECRET: "REMPLACER_32_CARACTERES_MINIMUM"
|
||||||
|
|
||||||
|
# --- SMTP (Brevo) ----------------------------------------------------------
|
||||||
|
# Creez une cle SMTP dediee a la production dans Brevo. Celle de la preprod
|
||||||
|
# est publiee dans le depot : elle doit etre revoquee.
|
||||||
|
SMTP_USER: "REMPLACER"
|
||||||
|
SMTP_PASS: "REMPLACER"
|
||||||
|
|
||||||
|
# --- Stockage objet Hetzner (documents applicatifs) ------------------------
|
||||||
|
# Jeu de cles DISTINCT de celui utilise par WAL-G pour les sauvegardes :
|
||||||
|
# une fuite cote application ne doit pas donner acces aux sauvegardes.
|
||||||
|
AWS_ACCESS_KEY_ID: "REMPLACER"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "REMPLACER"
|
||||||
|
|
||||||
|
# --- Stripe ----------------------------------------------------------------
|
||||||
|
# ATTENTION : cles LIVE en production (sk_live_..., whsec_...).
|
||||||
|
STRIPE_SECRET_KEY: "REMPLACER_sk_live"
|
||||||
|
STRIPE_WEBHOOK_SECRET: "REMPLACER_whsec"
|
||||||
|
|
||||||
|
# Noms exacts attendus par le code (app.module.ts / subscriptions).
|
||||||
|
# Le stack de preprod utilise STARTER/PRO/ENTERPRISE : ces noms-la ne sont lus
|
||||||
|
# par personne, les identifiants de tarif y sont donc silencieusement absents.
|
||||||
|
# Ne pas reproduire l'erreur ici.
|
||||||
|
STRIPE_SILVER_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||||
|
STRIPE_SILVER_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||||
|
STRIPE_GOLD_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||||
|
STRIPE_GOLD_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||||
|
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_"
|
||||||
|
STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_"
|
||||||
|
|
||||||
|
# --- Premier administrateur (FACULTATIF) -----------------------------------
|
||||||
|
# LAISSEZ VIDE dans le cas nominal.
|
||||||
|
#
|
||||||
|
# Vide + BOOTSTRAP_ADMIN_EMAIL renseigne dans le ConfigMap : le compte ADMIN
|
||||||
|
# est cree sans mot de passe utilisable, et vous definissez le votre via
|
||||||
|
# « mot de passe oublie ». Aucun secret n'existe nulle part -- rien a faire
|
||||||
|
# fuiter, et la reception du courriel prouve au passage que SMTP fonctionne.
|
||||||
|
#
|
||||||
|
# Ne renseignez ce champ que si vous devez pouvoir vous connecter AVANT que
|
||||||
|
# l'envoi de courriels ne soit operationnel. Dans ce cas :
|
||||||
|
# cd apps/backend && node scripts/setup/generate-admin-hash.js
|
||||||
|
# Le hash reste attaquable hors ligne : changez le mot de passe des la
|
||||||
|
# premiere connexion, puis RETIREZ cette valeur et reappliquez le Secret.
|
||||||
|
#
|
||||||
|
# Un mot de passe en clair place ici fait echouer la migration : la valeur
|
||||||
|
# doit commencer par "$argon2".
|
||||||
|
BOOTSTRAP_ADMIN_PASSWORD_HASH: ""
|
||||||
|
|
||||||
|
# --- Pappers (registre SIRET) ----------------------------------------------
|
||||||
|
# Facultatif : sans cle, la verification SIRET est simplement ignoree
|
||||||
|
# (pappers-siret.adapter.ts emet un avertissement au demarrage).
|
||||||
|
PAPPERS_API_KEY: "REMPLACER"
|
||||||
|
|
||||||
|
# --- Documentation Swagger -------------------------------------------------
|
||||||
|
# Laisser les deux valeurs VIDES en production : main.ts desactive alors
|
||||||
|
# completement /api/docs. Ne les renseigner que si vous avez vraiment besoin
|
||||||
|
# d'exposer la documentation, auquel cas elle passe derriere Basic Auth.
|
||||||
|
SWAGGER_USERNAME: ""
|
||||||
|
SWAGGER_PASSWORD: ""
|
||||||
|
|
||||||
|
# --- Connecteurs transporteurs (facultatifs) -------------------------------
|
||||||
|
# A renseigner uniquement quand les contrats API sont signes. Un connecteur
|
||||||
|
# sans identifiants est ignore, il ne fait pas planter le demarrage.
|
||||||
|
MAERSK_API_KEY: ""
|
||||||
|
MAERSK_API_BASE_URL: ""
|
||||||
|
MSC_API_KEY: ""
|
||||||
|
MSC_API_URL: ""
|
||||||
|
CMACGM_CLIENT_ID: ""
|
||||||
|
CMACGM_CLIENT_SECRET: ""
|
||||||
|
CMACGM_API_URL: ""
|
||||||
|
HAPAG_API_KEY: ""
|
||||||
|
HAPAG_API_URL: ""
|
||||||
|
ONE_USERNAME: ""
|
||||||
|
ONE_PASSWORD: ""
|
||||||
|
ONE_API_URL: ""
|
||||||
|
|
||||||
|
---
|
||||||
|
# Token API Cloudflare utilise par cert-manager pour le defi DNS-01.
|
||||||
|
# Permissions minimales : Zone / DNS / Edit sur la seule zone xpeditis.com.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: cloudflare-api-token
|
||||||
|
namespace: cert-manager
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
api-token: "REMPLACER"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Acces a Grafana. Mot de passe long : l'interface est exposee sur Internet,
|
||||||
|
# meme si elle est filtree par IP en amont.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: grafana-admin
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
admin-user: "REMPLACER"
|
||||||
|
admin-password: "REMPLACER"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Webhook Discord utilise par Alertmanager. Dans un Secret et non dans le
|
||||||
|
# ConfigMap : une URL de webhook permet de publier n'importe quoi dans le canal.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: alertmanager-secrets
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
discord-webhook: "https://discord.com/api/webhooks/REMPLACER"
|
||||||
216
infra/prod/k8s/base/04-backend.yaml
Normal file
216
infra/prod/k8s/base/04-backend.yaml
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Backend NestJS
|
||||||
|
# =============================================================================
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-backend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
revisionHistoryLimit: 5
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
# Aucun pod retire avant qu'un remplacant ne soit pret : zero coupure.
|
||||||
|
maxUnavailable: 0
|
||||||
|
maxSurge: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
app.kubernetes.io/component: api
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
annotations:
|
||||||
|
# Force le redemarrage des pods quand la configuration change : sans
|
||||||
|
# cela, modifier le ConfigMap ne produit aucun effet visible.
|
||||||
|
# La valeur est recalculee par scripts/deploy.sh.
|
||||||
|
xpeditis.com/config-checksum: "PLACEHOLDER"
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
# 2 replicas sur 1 noeud : la contrainte est "preferred", sinon le second
|
||||||
|
# pod resterait indefiniment en Pending. Elle deviendra effective le jour
|
||||||
|
# ou un second noeud sera ajoute (phase 2 du plan de charge).
|
||||||
|
topologySpreadConstraints:
|
||||||
|
- maxSkew: 1
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
whenUnsatisfiable: ScheduleAnyway
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
fsGroup: 1001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
# 60 s : laisse le temps aux requetes en cours et aux connexions
|
||||||
|
# WebSocket de se fermer proprement.
|
||||||
|
terminationGracePeriodSeconds: 60
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
# Le tag est remplace au deploiement (kubectl set image).
|
||||||
|
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 4000
|
||||||
|
protocol: TCP
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: xpeditis-backend-config
|
||||||
|
- secretRef:
|
||||||
|
name: xpeditis-backend-secrets
|
||||||
|
env:
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
|
||||||
|
# Le demarrage inclut l'attente de PostgreSQL puis la verification
|
||||||
|
# des migrations : la sonde de demarrage laisse jusqu'a 150 s avant
|
||||||
|
# de declarer le pod perdu, sans penaliser les redemarrages rapides.
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health/live
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 30
|
||||||
|
timeoutSeconds: 3
|
||||||
|
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health/live
|
||||||
|
port: http
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
# LIMITE CONNUE : /health/ready renvoie toujours "ready" sans tester
|
||||||
|
# PostgreSQL ni Redis (health.controller.ts). Un pod incapable de
|
||||||
|
# joindre la base sera donc declare pret. Correctif recommande apres
|
||||||
|
# la mise en ligne : @nestjs/terminus avec des indicateurs reels.
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/v1/health/ready
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 300m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1500m
|
||||||
|
memory: 1536Mi
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
# NON active volontairement : le conteneur ecrit dans /app/logs et
|
||||||
|
# dans /app/src/infrastructure/storage/csv-storage/rates (chemin
|
||||||
|
# resolu au runtime par le chargeur de grilles CSV). Monter des
|
||||||
|
# emptyDir par-dessus masquerait les fichiers livres dans l'image.
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
# Laisse Traefik retirer le pod de son pool avant que le
|
||||||
|
# processus ne commence a refuser des connexions.
|
||||||
|
command: ["sh", "-c", "sleep 10"]
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-backend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
annotations:
|
||||||
|
# Sessions collantes : obligatoires pour Socket.IO. La negociation
|
||||||
|
# long-polling echoue si deux requetes d'un meme handshake atterrissent sur
|
||||||
|
# des replicas differents.
|
||||||
|
#
|
||||||
|
# ATTENTION -- limite non resolue par ce reglage : le gateway
|
||||||
|
# (notifications.gateway.ts) garde la carte userId -> sockets EN MEMOIRE et
|
||||||
|
# n'utilise pas @socket.io/redis-adapter. Une notification emise par le
|
||||||
|
# replica A n'atteint donc pas un utilisateur connecte au replica B.
|
||||||
|
# Cf. docs/mise-en-prod/15-exploitation-incidents.md, "Points de vigilance".
|
||||||
|
traefik.ingress.kubernetes.io/service.sticky.cookie: "true"
|
||||||
|
traefik.ingress.kubernetes.io/service.sticky.cookie.name: "xpd_be"
|
||||||
|
traefik.ingress.kubernetes.io/service.sticky.cookie.secure: "true"
|
||||||
|
traefik.ingress.kubernetes.io/service.sticky.cookie.httponly: "true"
|
||||||
|
traefik.ingress.kubernetes.io/service.sticky.cookie.samesite: "lax"
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 4000
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-backend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
minAvailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-backend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: xpeditis-backend
|
||||||
|
minReplicas: 2
|
||||||
|
# Plafond a 4 : au-dela, le CPX41 sature. Passer a 3 noeuds avant d'augmenter
|
||||||
|
# (cf. 15-exploitation-incidents.md, section montee en charge).
|
||||||
|
maxReplicas: 4
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 70
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: memory
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 80
|
||||||
|
behavior:
|
||||||
|
scaleUp:
|
||||||
|
stabilizationWindowSeconds: 60
|
||||||
|
scaleDown:
|
||||||
|
# Descente lente : evite de retirer un pod juste avant un nouveau pic.
|
||||||
|
stabilizationWindowSeconds: 600
|
||||||
163
infra/prod/k8s/base/05-frontend.yaml
Normal file
163
infra/prod/k8s/base/05-frontend.yaml
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Frontend Next.js 14 (sortie standalone)
|
||||||
|
# =============================================================================
|
||||||
|
# RAPPEL CRITIQUE : NEXT_PUBLIC_API_URL est fige au moment du BUILD de l'image
|
||||||
|
# (next.config.js, bloc `env`). Une image construite pour la preprod pointera
|
||||||
|
# toujours vers api.preprod.xpeditis.com, quelles que soient les variables
|
||||||
|
# injectees ici. L'image du frontend doit donc etre RECONSTRUITE pour la prod,
|
||||||
|
# jamais promue depuis la preprod. Le workflow cd-main.yml applique cette regle.
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-frontend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
revisionHistoryLimit: 5
|
||||||
|
strategy:
|
||||||
|
type: RollingUpdate
|
||||||
|
rollingUpdate:
|
||||||
|
maxUnavailable: 0
|
||||||
|
maxSurge: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
|
app.kubernetes.io/component: web
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
topologySpreadConstraints:
|
||||||
|
- maxSkew: 1
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
whenUnsatisfiable: ScheduleAnyway
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
fsGroup: 1001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
terminationGracePeriodSeconds: 30
|
||||||
|
containers:
|
||||||
|
- name: frontend
|
||||||
|
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-frontend:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: NODE_ENV
|
||||||
|
value: "production"
|
||||||
|
- name: PORT
|
||||||
|
value: "3000"
|
||||||
|
- name: HOSTNAME
|
||||||
|
value: "0.0.0.0"
|
||||||
|
# Lues cote serveur uniquement (route handlers, server actions).
|
||||||
|
# Le code client, lui, a deja la valeur figee au build.
|
||||||
|
- name: NEXT_PUBLIC_API_URL
|
||||||
|
value: "https://api.xpeditis.com"
|
||||||
|
- name: NEXT_PUBLIC_APP_URL
|
||||||
|
value: "https://app.xpeditis.com"
|
||||||
|
- name: NEXT_TELEMETRY_DISABLED
|
||||||
|
value: "1"
|
||||||
|
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 3
|
||||||
|
failureThreshold: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
periodSeconds: 20
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 3
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 384Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
# Le build standalone n'ecrit qu'en cache : on peut donc verrouiller
|
||||||
|
# la racine et n'ouvrir que les deux repertoires necessaires.
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
|
||||||
|
volumeMounts:
|
||||||
|
- name: next-cache
|
||||||
|
mountPath: /app/.next/cache
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
preStop:
|
||||||
|
exec:
|
||||||
|
command: ["sh", "-c", "sleep 5"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: next-cache
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 512Mi
|
||||||
|
- name: tmp
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 128Mi
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-frontend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3000
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-frontend
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
minAvailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-frontend
|
||||||
73
infra/prod/k8s/base/06-log-exporter.yaml
Normal file
73
infra/prod/k8s/base/06-log-exporter.yaml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Log exporter -- API interne qui pousse des logs structures vers Loki
|
||||||
|
# =============================================================================
|
||||||
|
# Jamais expose par l'Ingress : uniquement joignable depuis le namespace
|
||||||
|
# (le backend l'appelle via LOG_EXPORTER_URL).
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-log-exporter
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
app.kubernetes.io/component: observability
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: log-exporter
|
||||||
|
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-log-exporter:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3200
|
||||||
|
env:
|
||||||
|
- name: PORT
|
||||||
|
value: "3200"
|
||||||
|
- name: LOKI_URL
|
||||||
|
value: "http://loki.monitoring.svc.cluster.local:3100"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 192Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-log-exporter
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3200
|
||||||
|
targetPort: http
|
||||||
76
infra/prod/k8s/base/07-migration-job.yaml
Normal file
76
infra/prod/k8s/base/07-migration-job.yaml
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Migrations de base de donnees -- Job execute AVANT chaque deploiement
|
||||||
|
# =============================================================================
|
||||||
|
# Pourquoi un Job separe alors que l'image lance deja les migrations au
|
||||||
|
# demarrage (scripts/setup/startup.js) :
|
||||||
|
#
|
||||||
|
# Avec 2 replicas, deux pods appellent runMigrations() simultanement. TypeORM
|
||||||
|
# ne serialise pas les migrations entre processus : l'un des deux echoue et
|
||||||
|
# part en CrashLoopBackOff. En passant par un Job (parallelisme 1) execute
|
||||||
|
# avant le `kubectl set image`, les migrations sont deja appliquees quand les
|
||||||
|
# pods demarrent : startup.js constate "no pending migrations" et laisse la
|
||||||
|
# main a l'application. Le comportement de l'image reste inchange, il devient
|
||||||
|
# simplement un filet de securite.
|
||||||
|
#
|
||||||
|
# Le nom du Job porte le tag de l'image : chaque deploiement cree son propre
|
||||||
|
# Job, ce qui laisse une trace consultable (kubectl get jobs).
|
||||||
|
# Le tag et le nom sont substitues par scripts/deploy.sh / cd-main.yml.
|
||||||
|
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-migrate-__IMAGE_TAG__
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-migrate
|
||||||
|
app.kubernetes.io/part-of: xpeditis
|
||||||
|
spec:
|
||||||
|
# Les Jobs termines s'effacent au bout d'une heure : pas d'accumulation.
|
||||||
|
ttlSecondsAfterFinished: 3600
|
||||||
|
backoffLimit: 2
|
||||||
|
activeDeadlineSeconds: 900
|
||||||
|
parallelism: 1
|
||||||
|
completions: 1
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: xpeditis-migrate
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1001
|
||||||
|
runAsGroup: 1001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: migrate
|
||||||
|
image: rg.fr-par.scw.cloud/weworkstudio/xpeditis-backend:__IMAGE_TAG__
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
# CLI TypeORM sur la source de donnees compilee. Elle honore
|
||||||
|
# DATABASE_SSL, contrairement au client de secours de startup.js.
|
||||||
|
command:
|
||||||
|
- node
|
||||||
|
- ./node_modules/typeorm/cli.js
|
||||||
|
- migration:run
|
||||||
|
- -d
|
||||||
|
- dist/infrastructure/persistence/typeorm/data-source.js
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: xpeditis-backend-config
|
||||||
|
- secretRef:
|
||||||
|
name: xpeditis-backend-secrets
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 384Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: false
|
||||||
148
infra/prod/k8s/base/08-traefik-middlewares.yaml
Normal file
148
infra/prod/k8s/base/08-traefik-middlewares.yaml
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Middlewares Traefik
|
||||||
|
# =============================================================================
|
||||||
|
# Reference dans les Ingress via l'annotation :
|
||||||
|
# traefik.ingress.kubernetes.io/router.middlewares:
|
||||||
|
# xpeditis-prod-<nom>@kubernetescrd
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: security-headers
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
headers:
|
||||||
|
# HSTS : 1 an, sous-domaines inclus, eligible au preload.
|
||||||
|
# A n'activer qu'une fois certain que TOUS les sous-domaines sont en HTTPS,
|
||||||
|
# car la decision est irreversible cote navigateur pendant un an.
|
||||||
|
stsSeconds: 31536000
|
||||||
|
stsIncludeSubdomains: true
|
||||||
|
stsPreload: true
|
||||||
|
forceSTSHeader: true
|
||||||
|
|
||||||
|
contentTypeNosniff: true
|
||||||
|
browserXssFilter: true
|
||||||
|
referrerPolicy: "strict-origin-when-cross-origin"
|
||||||
|
|
||||||
|
customResponseHeaders:
|
||||||
|
# DENY et non SAMEORIGIN : l'application n'a aucun usage legitime d'une
|
||||||
|
# iframe, et c'est la meilleure protection contre le clickjacking.
|
||||||
|
X-Frame-Options: "DENY"
|
||||||
|
Permissions-Policy: "camera=(), microphone=(), geolocation=(), payment=(self), interest-cohort=()"
|
||||||
|
Cross-Origin-Opener-Policy: "same-origin"
|
||||||
|
Cross-Origin-Resource-Policy: "same-site"
|
||||||
|
# Masque la stack technique.
|
||||||
|
Server: ""
|
||||||
|
X-Powered-By: ""
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: rate-limit
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
rateLimit:
|
||||||
|
# Limite de bordure, volontairement large : elle protege l'infrastructure.
|
||||||
|
# La limite metier fine reste celle de CustomThrottlerGuard cote NestJS.
|
||||||
|
average: 50
|
||||||
|
period: 1s
|
||||||
|
burst: 100
|
||||||
|
sourceCriterion:
|
||||||
|
ipStrategy:
|
||||||
|
# 1 = on prend l'avant-derniere IP de X-Forwarded-For, c'est-a-dire
|
||||||
|
# celle que Cloudflare a inscrite : la vraie IP du visiteur.
|
||||||
|
# Sans cela toutes les requetes seraient comptees sur l'IP Cloudflare
|
||||||
|
# et un seul abuseur ferait tomber la limite pour tout le monde.
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: rate-limit-auth
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
rateLimit:
|
||||||
|
# Beaucoup plus stricte sur /api/v1/auth : connexion, inscription,
|
||||||
|
# reinitialisation de mot de passe, liens magiques transporteurs.
|
||||||
|
# 5 req/s en pointe suffit largement a un usage humain.
|
||||||
|
average: 5
|
||||||
|
period: 1s
|
||||||
|
burst: 10
|
||||||
|
sourceCriterion:
|
||||||
|
ipStrategy:
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: compress
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
compress:
|
||||||
|
minResponseBodyBytes: 1024
|
||||||
|
excludedContentTypes:
|
||||||
|
- image/png
|
||||||
|
- image/jpeg
|
||||||
|
- image/webp
|
||||||
|
- application/pdf
|
||||||
|
|
||||||
|
---
|
||||||
|
# Chaine appliquee au trafic public standard.
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: public-chain
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
chain:
|
||||||
|
middlewares:
|
||||||
|
- name: security-headers
|
||||||
|
- name: rate-limit
|
||||||
|
- name: compress
|
||||||
|
|
||||||
|
---
|
||||||
|
# Chaine appliquee aux routes d'authentification.
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: auth-chain
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
chain:
|
||||||
|
middlewares:
|
||||||
|
- name: security-headers
|
||||||
|
- name: rate-limit-auth
|
||||||
|
- name: compress
|
||||||
|
|
||||||
|
---
|
||||||
|
# Restriction par IP, utilisee pour Grafana. Remplacez les valeurs par vos IP
|
||||||
|
# d'administration -- les memes que admin_ip_allowlist cote Terraform.
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: admin-ip-allowlist
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
ipAllowList:
|
||||||
|
sourceRange:
|
||||||
|
- 203.0.113.7/32
|
||||||
|
ipStrategy:
|
||||||
|
depth: 1
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: traefik.io/v1alpha1
|
||||||
|
kind: Middleware
|
||||||
|
metadata:
|
||||||
|
name: security-headers
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
headers:
|
||||||
|
stsSeconds: 31536000
|
||||||
|
stsIncludeSubdomains: true
|
||||||
|
contentTypeNosniff: true
|
||||||
|
referrerPolicy: "strict-origin-when-cross-origin"
|
||||||
|
customResponseHeaders:
|
||||||
|
X-Frame-Options: "DENY"
|
||||||
128
infra/prod/k8s/base/09-ingress.yaml
Normal file
128
infra/prod/k8s/base/09-ingress.yaml
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Exposition publique
|
||||||
|
# =============================================================================
|
||||||
|
# Cartographie des domaines :
|
||||||
|
#
|
||||||
|
# xpeditis.com -> frontend (vitrine, pages publiques)
|
||||||
|
# www.xpeditis.com -> frontend
|
||||||
|
# app.xpeditis.com -> frontend (dashboard ; c'est l'origine des cookies)
|
||||||
|
# api.xpeditis.com -> backend NestJS
|
||||||
|
# grafana.xpeditis.com-> Grafana (voir k8s/monitoring/)
|
||||||
|
#
|
||||||
|
# Le certificat est un wildcard *.xpeditis.com + xpeditis.com, obtenu par
|
||||||
|
# cert-manager en DNS-01 Cloudflare (cf. 11-certificate.yaml).
|
||||||
|
# La redirection HTTP -> HTTPS est faite au niveau de l'entrypoint Traefik,
|
||||||
|
# aucun Ingress ne peut l'oublier.
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- API : routes d'authentification (limitation stricte) --------------------
|
||||||
|
# Ingress separe et plus specifique que celui de l'API : Traefik privilegie la
|
||||||
|
# regle au chemin le plus long, cette limite s'applique donc bien en priorite.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-api-auth
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-auth-chain@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- api.xpeditis.com
|
||||||
|
secretName: xpeditis-wildcard-tls
|
||||||
|
rules:
|
||||||
|
- host: api.xpeditis.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /api/v1/auth
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: xpeditis-backend
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- API : tout le reste -----------------------------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-api
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- api.xpeditis.com
|
||||||
|
secretName: xpeditis-wildcard-tls
|
||||||
|
rules:
|
||||||
|
- host: api.xpeditis.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
# Couvre l'API REST, le webhook Stripe et le handshake Socket.IO
|
||||||
|
# (/socket.io/...), Traefik gerant l'upgrade WebSocket nativement.
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: xpeditis-backend
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Frontend ----------------------------------------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-app
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: xpeditis-prod-public-chain@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- xpeditis.com
|
||||||
|
- www.xpeditis.com
|
||||||
|
- app.xpeditis.com
|
||||||
|
secretName: xpeditis-wildcard-tls
|
||||||
|
rules:
|
||||||
|
- host: app.xpeditis.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: xpeditis-frontend
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
- host: www.xpeditis.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: xpeditis-frontend
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
- host: xpeditis.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: xpeditis-frontend
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
239
infra/prod/k8s/base/10-network-policies.yaml
Normal file
239
infra/prod/k8s/base/10-network-policies.yaml
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Politiques reseau
|
||||||
|
# =============================================================================
|
||||||
|
# k3s applique nativement les NetworkPolicy (controleur kube-router embarque).
|
||||||
|
#
|
||||||
|
# Objectif : qu'un pod compromis ne puisse ni etre joint par n'importe qui, ni
|
||||||
|
# se servir du cluster comme point de rebond vers le reseau prive Hetzner.
|
||||||
|
# Sans ces regles, tout pod peut joindre tout pod ET toute IP privee.
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Refus par defaut, entrant ET sortant -----------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: default-deny
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector: {}
|
||||||
|
policyTypes: [Ingress, Egress]
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Resolution DNS (indispensable, sinon plus rien ne fonctionne) ----------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-dns
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector: {}
|
||||||
|
policyTypes: [Egress]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
ports:
|
||||||
|
- protocol: UDP
|
||||||
|
port: 53
|
||||||
|
- protocol: TCP
|
||||||
|
port: 53
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Trafic entrant depuis l'ingress ----------------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-ingress-from-traefik
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/name
|
||||||
|
operator: In
|
||||||
|
values: [xpeditis-backend, xpeditis-frontend]
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 4000
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Le backend ecrit dans le log-exporter ----------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-backend-to-log-exporter
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3200
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-egress-to-log-exporter
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-backend
|
||||||
|
policyTypes: [Egress]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3200
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Le log-exporter pousse vers Loki ---------------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-log-exporter-to-loki
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: xpeditis-log-exporter
|
||||||
|
policyTypes: [Egress]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: monitoring
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3100
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Acces a PostgreSQL et Redis sur db-01 ----------------------------------
|
||||||
|
# Une seule IP, deux ports. Tout autre acces au reseau prive est refuse : un
|
||||||
|
# backend compromis ne peut pas scanner 10.10.0.0/16.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-egress-to-data-node
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/name
|
||||||
|
operator: In
|
||||||
|
values: [xpeditis-backend, xpeditis-migrate]
|
||||||
|
policyTypes: [Egress]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 10.10.1.20/32
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 5432
|
||||||
|
- protocol: TCP
|
||||||
|
port: 6379
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Sorties Internet (Stripe, Brevo, Object Storage, Pappers, carriers) ----
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-egress-internet
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchExpressions:
|
||||||
|
- key: app.kubernetes.io/name
|
||||||
|
operator: In
|
||||||
|
values: [xpeditis-backend, xpeditis-frontend, xpeditis-migrate]
|
||||||
|
policyTypes: [Egress]
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- ipBlock:
|
||||||
|
cidr: 0.0.0.0/0
|
||||||
|
# Tout l'espace prive est exclu : la sortie ne sert qu'a joindre
|
||||||
|
# des services publics, jamais l'infrastructure interne.
|
||||||
|
except:
|
||||||
|
- 10.0.0.0/8
|
||||||
|
- 172.16.0.0/12
|
||||||
|
- 192.168.0.0/16
|
||||||
|
- 169.254.0.0/16 # metadonnees cloud
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 443
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
- protocol: TCP
|
||||||
|
port: 587 # SMTP soumission (Brevo)
|
||||||
|
|
||||||
|
---
|
||||||
|
# --- Namespace monitoring ----------------------------------------------------
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: default-deny
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
podSelector: {}
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-loki-writes
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: loki
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
# Promtail (meme namespace) et le log-exporter applicatif.
|
||||||
|
- from:
|
||||||
|
- podSelector: {}
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: xpeditis-prod
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3100
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: allow-grafana-from-traefik
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: grafana
|
||||||
|
policyTypes: [Ingress]
|
||||||
|
ingress:
|
||||||
|
- from:
|
||||||
|
- namespaceSelector:
|
||||||
|
matchLabels:
|
||||||
|
kubernetes.io/metadata.name: kube-system
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 3000
|
||||||
59
infra/prod/k8s/base/11-certificate.yaml
Normal file
59
infra/prod/k8s/base/11-certificate.yaml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Certificats TLS (Let's Encrypt via cert-manager, defi DNS-01 Cloudflare)
|
||||||
|
# =============================================================================
|
||||||
|
# Pourquoi DNS-01 et non HTTP-01 :
|
||||||
|
# - il fonctionne meme quand le proxy Cloudflare (nuage orange) est actif et
|
||||||
|
# que le firewall Hetzner n'accepte que les IP Cloudflare ;
|
||||||
|
# - il permet un certificat wildcard, donc un seul certificat pour tous les
|
||||||
|
# sous-domaines presents et futurs ;
|
||||||
|
# - il ne depend pas de la joignabilite du port 80.
|
||||||
|
#
|
||||||
|
# Un certificat est renouvele 30 jours avant expiration. Surveillez l'alerte
|
||||||
|
# "certificat expirant" (cf. k8s/monitoring/) : un renouvellement casse passe
|
||||||
|
# inapercu jusqu'au jour ou le site devient inaccessible.
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: xpeditis-wildcard
|
||||||
|
namespace: xpeditis-prod
|
||||||
|
spec:
|
||||||
|
secretName: xpeditis-wildcard-tls
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
kind: ClusterIssuer
|
||||||
|
commonName: xpeditis.com
|
||||||
|
dnsNames:
|
||||||
|
# Le wildcard ne couvre PAS l'apex : les deux doivent etre listes.
|
||||||
|
- xpeditis.com
|
||||||
|
- "*.xpeditis.com"
|
||||||
|
duration: 2160h # 90 jours
|
||||||
|
renewBefore: 720h # 30 jours
|
||||||
|
privateKey:
|
||||||
|
algorithm: ECDSA
|
||||||
|
size: 256
|
||||||
|
# Nouvelle cle a chaque renouvellement : limite la fenetre d'exploitation
|
||||||
|
# d'une cle qui aurait fuite.
|
||||||
|
rotationPolicy: Always
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: grafana-tls
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
secretName: grafana-tls
|
||||||
|
issuerRef:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
kind: ClusterIssuer
|
||||||
|
commonName: grafana.xpeditis.com
|
||||||
|
dnsNames:
|
||||||
|
- grafana.xpeditis.com
|
||||||
|
duration: 2160h
|
||||||
|
renewBefore: 720h
|
||||||
|
privateKey:
|
||||||
|
algorithm: ECDSA
|
||||||
|
size: 256
|
||||||
|
rotationPolicy: Always
|
||||||
56
infra/prod/k8s/cluster/cluster-issuer.yaml
Normal file
56
infra/prod/k8s/cluster/cluster-issuer.yaml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Emetteurs ACME
|
||||||
|
# =============================================================================
|
||||||
|
# Prerequis : le Secret cert-manager/cloudflare-api-token doit exister
|
||||||
|
# (bash scripts/secrets-apply.sh).
|
||||||
|
#
|
||||||
|
# Le token Cloudflare doit avoir la permission MINIMALE :
|
||||||
|
# Zone > DNS > Edit, limite a la seule zone xpeditis.com.
|
||||||
|
# Un token global de compte donnerait a cert-manager le pouvoir de rediriger
|
||||||
|
# tout votre trafic.
|
||||||
|
|
||||||
|
---
|
||||||
|
# Emetteur de TEST. Ses certificats ne sont pas reconnus par les navigateurs,
|
||||||
|
# mais il n'a pas de quota serre : utilisez-le pour valider la chaine DNS-01
|
||||||
|
# AVANT de basculer sur l'emetteur de production.
|
||||||
|
# Let's Encrypt limite la production a 5 echecs/heure et 50 certificats/semaine
|
||||||
|
# par domaine : on ne debogue pas dessus.
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: ClusterIssuer
|
||||||
|
metadata:
|
||||||
|
name: letsencrypt-staging
|
||||||
|
spec:
|
||||||
|
acme:
|
||||||
|
server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
email: ops@xpeditis.com
|
||||||
|
privateKeySecretRef:
|
||||||
|
name: letsencrypt-staging-account-key
|
||||||
|
solvers:
|
||||||
|
- dns01:
|
||||||
|
cloudflare:
|
||||||
|
apiTokenSecretRef:
|
||||||
|
name: cloudflare-api-token
|
||||||
|
key: api-token
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: ClusterIssuer
|
||||||
|
metadata:
|
||||||
|
name: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
acme:
|
||||||
|
server: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
# Adresse REELLE et surveillee : Let's Encrypt y envoie les avertissements
|
||||||
|
# d'expiration si le renouvellement automatique cesse de fonctionner.
|
||||||
|
email: ops@xpeditis.com
|
||||||
|
privateKeySecretRef:
|
||||||
|
name: letsencrypt-prod-account-key
|
||||||
|
solvers:
|
||||||
|
- dns01:
|
||||||
|
cloudflare:
|
||||||
|
apiTokenSecretRef:
|
||||||
|
name: cloudflare-api-token
|
||||||
|
key: api-token
|
||||||
|
selector:
|
||||||
|
dnsZones:
|
||||||
|
- xpeditis.com
|
||||||
172
infra/prod/k8s/monitoring/01-loki.yaml
Normal file
172
infra/prod/k8s/monitoring/01-loki.yaml
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Loki -- agregation des journaux
|
||||||
|
# =============================================================================
|
||||||
|
# Mode "single binary", stockage sur le disque local du noeud (local-path de
|
||||||
|
# k3s). Suffisant jusqu'a ~10 Go/mois de logs, ce qui couvre tres largement la
|
||||||
|
# phase 1. Au-dela, basculer le stockage sur Hetzner Object Storage.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: loki-config
|
||||||
|
namespace: monitoring
|
||||||
|
data:
|
||||||
|
loki.yaml: |
|
||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
grpc_listen_port: 9096
|
||||||
|
log_level: warn
|
||||||
|
|
||||||
|
common:
|
||||||
|
instance_addr: 127.0.0.1
|
||||||
|
path_prefix: /loki
|
||||||
|
storage:
|
||||||
|
filesystem:
|
||||||
|
chunks_directory: /loki/chunks
|
||||||
|
rules_directory: /loki/rules
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2024-01-01
|
||||||
|
store: tsdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v13
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
limits_config:
|
||||||
|
allow_structured_metadata: true
|
||||||
|
volume_enabled: true
|
||||||
|
# 31 jours : couvre l'analyse d'incident et reste sous le seuil ou les
|
||||||
|
# journaux applicatifs deviendraient une base de donnees personnelles
|
||||||
|
# a part entiere au sens du RGPD (cf. 16 dans la doc de mise en prod).
|
||||||
|
retention_period: 744h
|
||||||
|
reject_old_samples: true
|
||||||
|
reject_old_samples_max_age: 168h
|
||||||
|
ingestion_rate_mb: 8
|
||||||
|
ingestion_burst_size_mb: 16
|
||||||
|
max_entries_limit_per_query: 5000
|
||||||
|
max_query_series: 500
|
||||||
|
|
||||||
|
compactor:
|
||||||
|
working_directory: /loki/compactor
|
||||||
|
compaction_interval: 10m
|
||||||
|
retention_enabled: true
|
||||||
|
retention_delete_delay: 2h
|
||||||
|
retention_delete_worker_count: 50
|
||||||
|
delete_request_store: filesystem
|
||||||
|
|
||||||
|
query_range:
|
||||||
|
results_cache:
|
||||||
|
cache:
|
||||||
|
embedded_cache:
|
||||||
|
enabled: true
|
||||||
|
max_size_mb: 100
|
||||||
|
|
||||||
|
analytics:
|
||||||
|
reporting_enabled: false
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: loki-data
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 20Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: loki
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: loki
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
# Un seul volume RWO : on remplace avant de recreer, jamais l'inverse.
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: loki
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: loki
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 10001
|
||||||
|
runAsGroup: 10001
|
||||||
|
fsGroup: 10001
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: loki
|
||||||
|
image: grafana/loki:3.3.2
|
||||||
|
args: ["-config.file=/etc/loki/loki.yaml"]
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3100
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ready
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ready
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1Gi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/loki
|
||||||
|
- name: data
|
||||||
|
mountPath: /loki
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: loki-config
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: loki-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: loki
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: loki
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: loki
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3100
|
||||||
|
targetPort: http
|
||||||
185
infra/prod/k8s/monitoring/02-promtail.yaml
Normal file
185
infra/prod/k8s/monitoring/02-promtail.yaml
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Promtail -- collecte des journaux de conteneurs
|
||||||
|
# =============================================================================
|
||||||
|
# k3s utilise containerd, pas Docker : la decouverte se fait via l'API
|
||||||
|
# Kubernetes et la lecture de /var/log/pods, et non via le socket Docker comme
|
||||||
|
# en preprod.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: promtail
|
||||||
|
namespace: monitoring
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: promtail
|
||||||
|
rules:
|
||||||
|
# Lecture seule, strictement ce qu'exige la decouverte de pods.
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [nodes, nodes/proxy, services, endpoints, pods]
|
||||||
|
verbs: [get, list, watch]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: promtail
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: promtail
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: promtail
|
||||||
|
namespace: monitoring
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: promtail-config
|
||||||
|
namespace: monitoring
|
||||||
|
data:
|
||||||
|
promtail.yaml: |
|
||||||
|
server:
|
||||||
|
http_listen_port: 9080
|
||||||
|
log_level: warn
|
||||||
|
|
||||||
|
positions:
|
||||||
|
filename: /run/promtail/positions.yaml
|
||||||
|
|
||||||
|
clients:
|
||||||
|
- url: http://loki:3100/loki/api/v1/push
|
||||||
|
batchwait: 1s
|
||||||
|
batchsize: 1048576
|
||||||
|
timeout: 10s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: kubernetes-pods
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: pod
|
||||||
|
pipeline_stages:
|
||||||
|
# Format CRI de containerd : "<ts> <stream> <flags> <message>".
|
||||||
|
- cri: {}
|
||||||
|
|
||||||
|
- drop:
|
||||||
|
older_than: 15m
|
||||||
|
drop_counter_reason: entry_too_old
|
||||||
|
|
||||||
|
# Les sondes de sante representent l'essentiel du volume et n'ont
|
||||||
|
# aucune valeur d'analyse : on les jette avant ingestion.
|
||||||
|
- drop:
|
||||||
|
expression: '(GET /api/v1/health|GET /api/health|/ready|/metrics)'
|
||||||
|
drop_counter_reason: healthcheck
|
||||||
|
|
||||||
|
# Journaux pino du backend (LOG_FORMAT=json).
|
||||||
|
- json:
|
||||||
|
expressions:
|
||||||
|
level: level
|
||||||
|
msg: msg
|
||||||
|
context: context
|
||||||
|
reqId: reqId
|
||||||
|
- template:
|
||||||
|
source: level
|
||||||
|
template: >-
|
||||||
|
{{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }}
|
||||||
|
- labels:
|
||||||
|
level:
|
||||||
|
context:
|
||||||
|
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_pod_node_name]
|
||||||
|
target_label: node
|
||||||
|
- source_labels: [__meta_kubernetes_namespace]
|
||||||
|
target_label: namespace
|
||||||
|
- source_labels: [__meta_kubernetes_pod_name]
|
||||||
|
target_label: pod
|
||||||
|
- source_labels: [__meta_kubernetes_pod_container_name]
|
||||||
|
target_label: container
|
||||||
|
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||||
|
target_label: service
|
||||||
|
# Chemin reel des journaux sur l'hote.
|
||||||
|
- source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
|
||||||
|
target_label: __path__
|
||||||
|
separator: /
|
||||||
|
replacement: /var/log/pods/*$1/*.log
|
||||||
|
# On ne collecte que les namespaces utiles : pas de bruit kube-system
|
||||||
|
# hormis l'ingress.
|
||||||
|
- source_labels: [__meta_kubernetes_namespace]
|
||||||
|
regex: (xpeditis-prod|monitoring|kube-system)
|
||||||
|
action: keep
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: promtail
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: promtail
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: promtail
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: promtail
|
||||||
|
spec:
|
||||||
|
serviceAccountName: promtail
|
||||||
|
securityContext:
|
||||||
|
# Les journaux de /var/log/pods appartiennent a root : Promtail doit
|
||||||
|
# pouvoir les lire. C'est la raison pour laquelle le namespace
|
||||||
|
# monitoring est en "baseline" et non "restricted".
|
||||||
|
runAsUser: 0
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: promtail
|
||||||
|
image: grafana/promtail:3.3.2
|
||||||
|
args: ["-config.file=/etc/promtail/promtail.yaml"]
|
||||||
|
env:
|
||||||
|
- name: HOSTNAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: spec.nodeName
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 9080
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 300m
|
||||||
|
memory: 256Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/promtail
|
||||||
|
- name: positions
|
||||||
|
mountPath: /run/promtail
|
||||||
|
- name: pods
|
||||||
|
mountPath: /var/log/pods
|
||||||
|
readOnly: true
|
||||||
|
- name: containers
|
||||||
|
mountPath: /var/lib/rancher/k3s/agent/containerd
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: promtail-config
|
||||||
|
- name: positions
|
||||||
|
emptyDir: {}
|
||||||
|
- name: pods
|
||||||
|
hostPath:
|
||||||
|
path: /var/log/pods
|
||||||
|
- name: containers
|
||||||
|
hostPath:
|
||||||
|
path: /var/lib/rancher/k3s/agent/containerd
|
||||||
|
tolerations:
|
||||||
|
- effect: NoSchedule
|
||||||
|
operator: Exists
|
||||||
351
infra/prod/k8s/monitoring/03-prometheus.yaml
Normal file
351
infra/prod/k8s/monitoring/03-prometheus.yaml
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Prometheus -- metriques et regles d'alerte
|
||||||
|
# =============================================================================
|
||||||
|
# Perimetre volontairement reduit : kubelet/cAdvisor, node-exporter, Traefik,
|
||||||
|
# cert-manager et postgres-exporter. Pas de kube-state-metrics ni d'operateur :
|
||||||
|
# a un noeud et une dizaine de pods, ils coutent plus de RAM qu'ils n'apportent.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
namespace: monitoring
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
|
||||||
|
verbs: [get, list, watch]
|
||||||
|
- nonResourceURLs: ["/metrics", "/metrics/cadvisor"]
|
||||||
|
verbs: [get]
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: prometheus
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: prometheus
|
||||||
|
namespace: monitoring
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: prometheus-config
|
||||||
|
namespace: monitoring
|
||||||
|
data:
|
||||||
|
prometheus.yml: |
|
||||||
|
global:
|
||||||
|
scrape_interval: 30s
|
||||||
|
evaluation_interval: 30s
|
||||||
|
external_labels:
|
||||||
|
cluster: xpeditis-prod
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yml
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: ['alertmanager:9093']
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
- job_name: node-exporter
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: endpoints
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_service_name]
|
||||||
|
regex: node-exporter
|
||||||
|
action: keep
|
||||||
|
|
||||||
|
# cAdvisor : consommation CPU/memoire par conteneur.
|
||||||
|
- job_name: kubelet-cadvisor
|
||||||
|
scheme: https
|
||||||
|
tls_config:
|
||||||
|
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
||||||
|
insecure_skip_verify: true
|
||||||
|
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: node
|
||||||
|
relabel_configs:
|
||||||
|
- action: labelmap
|
||||||
|
regex: __meta_kubernetes_node_label_(.+)
|
||||||
|
- target_label: __address__
|
||||||
|
replacement: kubernetes.default.svc:443
|
||||||
|
- source_labels: [__meta_kubernetes_node_name]
|
||||||
|
regex: (.+)
|
||||||
|
target_label: __metrics_path__
|
||||||
|
replacement: /api/v1/nodes/${1}/proxy/metrics/cadvisor
|
||||||
|
|
||||||
|
- job_name: traefik
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: pod
|
||||||
|
namespaces:
|
||||||
|
names: [kube-system]
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
|
||||||
|
regex: traefik
|
||||||
|
action: keep
|
||||||
|
- source_labels: [__address__]
|
||||||
|
regex: '(.+?)(?::\d+)?'
|
||||||
|
target_label: __address__
|
||||||
|
replacement: '${1}:9100'
|
||||||
|
|
||||||
|
- job_name: cert-manager
|
||||||
|
kubernetes_sd_configs:
|
||||||
|
- role: pod
|
||||||
|
namespaces:
|
||||||
|
names: [cert-manager]
|
||||||
|
relabel_configs:
|
||||||
|
- source_labels: [__meta_kubernetes_pod_container_port_number]
|
||||||
|
regex: "9402"
|
||||||
|
action: keep
|
||||||
|
|
||||||
|
# PostgreSQL : l'exportateur tourne sur db-01, hors du cluster.
|
||||||
|
- job_name: postgres
|
||||||
|
static_configs:
|
||||||
|
- targets: ['10.10.1.20:9187']
|
||||||
|
labels:
|
||||||
|
instance: xpeditis-prod-db-01
|
||||||
|
|
||||||
|
- job_name: loki
|
||||||
|
static_configs:
|
||||||
|
- targets: ['loki:3100']
|
||||||
|
|
||||||
|
rules.yml: |
|
||||||
|
groups:
|
||||||
|
- name: xpeditis-disponibilite
|
||||||
|
rules:
|
||||||
|
- alert: BackendIndisponible
|
||||||
|
# Traefik ne voit plus aucune instance saine derriere le service :
|
||||||
|
# l'API est hors ligne pour les utilisateurs, quoi que dise k8s.
|
||||||
|
expr: |
|
||||||
|
sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-backend.*"}) == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "Aucune instance backend saine derriere l'ingress"
|
||||||
|
description: "L'API Xpeditis ne repond plus. Verifier : kubectl -n xpeditis-prod get pods"
|
||||||
|
|
||||||
|
- alert: FrontendIndisponible
|
||||||
|
expr: |
|
||||||
|
sum by (service) (traefik_service_server_up{service=~"xpeditis-prod-xpeditis-frontend.*"}) == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "Aucune instance frontend saine derriere l'ingress"
|
||||||
|
|
||||||
|
- alert: TauxErreur5xxEleve
|
||||||
|
expr: |
|
||||||
|
sum(rate(traefik_service_requests_total{code=~"5.."}[5m]))
|
||||||
|
/ clamp_min(sum(rate(traefik_service_requests_total[5m])), 0.001) > 0.05
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "Plus de 5% de reponses 5xx"
|
||||||
|
description: "Taux d'erreur serveur anormal sur les 5 dernieres minutes."
|
||||||
|
|
||||||
|
- alert: LatenceElevee
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.95,
|
||||||
|
sum(rate(traefik_service_request_duration_seconds_bucket[5m])) by (le, service)) > 2
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: avertissement
|
||||||
|
annotations:
|
||||||
|
summary: "P95 au-dessus de 2 s sur {{ $labels.service }}"
|
||||||
|
|
||||||
|
- name: xpeditis-ressources
|
||||||
|
rules:
|
||||||
|
- alert: DisqueBientotPlein
|
||||||
|
# Un disque plein arrete PostgreSQL et k3s. Seuil a 15% restants
|
||||||
|
# pour laisser le temps d'intervenir.
|
||||||
|
expr: |
|
||||||
|
node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"}
|
||||||
|
/ node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"} < 0.15
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "Moins de 15% d'espace disque libre sur {{ $labels.instance }}"
|
||||||
|
|
||||||
|
- alert: MemoireNoeudSaturee
|
||||||
|
expr: |
|
||||||
|
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) > 0.90
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: avertissement
|
||||||
|
annotations:
|
||||||
|
summary: "Memoire du noeud a plus de 90%"
|
||||||
|
|
||||||
|
- alert: ConteneurRedemarreEnBoucle
|
||||||
|
expr: |
|
||||||
|
increase(container_start_time_seconds{namespace="xpeditis-prod"}[15m]) > 3
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "Le conteneur {{ $labels.container }} redemarre en boucle"
|
||||||
|
|
||||||
|
- name: xpeditis-donnees
|
||||||
|
rules:
|
||||||
|
- alert: PostgresInjoignable
|
||||||
|
expr: pg_up == 0
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "PostgreSQL ne repond plus sur db-01"
|
||||||
|
description: "Verifier db-01 : docker compose ps, journalctl -u docker"
|
||||||
|
|
||||||
|
- alert: ConnexionsPostgresSaturees
|
||||||
|
expr: |
|
||||||
|
sum(pg_stat_database_numbackends) / on() pg_settings_max_connections > 0.80
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: avertissement
|
||||||
|
annotations:
|
||||||
|
summary: "Plus de 80% des connexions PostgreSQL consommees"
|
||||||
|
|
||||||
|
# NOTE -- la surveillance des sauvegardes ne passe PAS par Prometheus.
|
||||||
|
# Une alerte "la sauvegarde n'a pas tourne" evaluee par un composant
|
||||||
|
# heberge sur la meme machine ne se declenche pas quand la machine est
|
||||||
|
# eteinte, c'est-a-dire precisement quand elle serait utile.
|
||||||
|
# Elle repose donc sur un battement de coeur externe : pg-backup.sh
|
||||||
|
# appelle BACKUP_HEARTBEAT_URL a chaque succes, et le service externe
|
||||||
|
# (BetterStack / healthchecks.io) alerte en cas de silence.
|
||||||
|
# Cf. docs/mise-en-prod/12-sauvegardes-restauration.md.
|
||||||
|
|
||||||
|
- name: xpeditis-tls
|
||||||
|
rules:
|
||||||
|
- alert: CertificatBientotExpire
|
||||||
|
expr: |
|
||||||
|
(certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 15
|
||||||
|
for: 1h
|
||||||
|
labels:
|
||||||
|
severity: critique
|
||||||
|
annotations:
|
||||||
|
summary: "Certificat {{ $labels.name }} expire dans moins de 15 jours"
|
||||||
|
description: "Le renouvellement automatique ne fonctionne plus. Verifier : kubectl describe certificate -A"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: prometheus-data
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 15Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: prometheus
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: prometheus
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: prometheus
|
||||||
|
annotations:
|
||||||
|
# Redemarre Prometheus quand les regles changent.
|
||||||
|
checksum/config: "PLACEHOLDER"
|
||||||
|
spec:
|
||||||
|
serviceAccountName: prometheus
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65534
|
||||||
|
runAsGroup: 65534
|
||||||
|
fsGroup: 65534
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: prometheus
|
||||||
|
image: prom/prometheus:v3.1.0
|
||||||
|
args:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
# 15 jours : suffisant pour l'analyse d'incident, tient dans 15 Go.
|
||||||
|
- --storage.tsdb.retention.time=15d
|
||||||
|
- --web.enable-lifecycle
|
||||||
|
- --web.listen-address=:9090
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 9090
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /-/ready
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /-/healthy
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 150m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1000m
|
||||||
|
memory: 1536Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/prometheus/prometheus.yml
|
||||||
|
subPath: prometheus.yml
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/prometheus/rules/rules.yml
|
||||||
|
subPath: rules.yml
|
||||||
|
- name: data
|
||||||
|
mountPath: /prometheus
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: prometheus-config
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: prometheus-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: prometheus
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: prometheus
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 9090
|
||||||
|
targetPort: http
|
||||||
95
infra/prod/k8s/monitoring/04-node-exporter.yaml
Normal file
95
infra/prod/k8s/monitoring/04-node-exporter.yaml
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# node-exporter -- metriques systeme du noeud
|
||||||
|
# =============================================================================
|
||||||
|
# Presence justifiee par une seule alerte, mais la plus importante de toutes :
|
||||||
|
# le disque plein. Un disque sature arrete PostgreSQL, k3s et les sauvegardes
|
||||||
|
# en meme temps, et c'est la panne la plus frequente d'un serveur laisse seul.
|
||||||
|
#
|
||||||
|
# Port 9101 et non 9100 : 9100 est deja l'entrypoint metrics de Traefik, et
|
||||||
|
# confondre les deux cibles rend le diagnostic penible.
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: DaemonSet
|
||||||
|
metadata:
|
||||||
|
name: node-exporter
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: node-exporter
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: node-exporter
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: node-exporter
|
||||||
|
spec:
|
||||||
|
hostNetwork: true
|
||||||
|
hostPID: true
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65534
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: node-exporter
|
||||||
|
image: prom/node-exporter:v1.8.2
|
||||||
|
args:
|
||||||
|
- --path.procfs=/host/proc
|
||||||
|
- --path.sysfs=/host/sys
|
||||||
|
- --path.rootfs=/host/root
|
||||||
|
- --web.listen-address=:9101
|
||||||
|
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+|var/lib/rancher/.+)($|/)
|
||||||
|
ports:
|
||||||
|
- name: metrics
|
||||||
|
containerPort: 9101
|
||||||
|
hostPort: 9101
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 20m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: proc
|
||||||
|
mountPath: /host/proc
|
||||||
|
readOnly: true
|
||||||
|
- name: sys
|
||||||
|
mountPath: /host/sys
|
||||||
|
readOnly: true
|
||||||
|
- name: root
|
||||||
|
mountPath: /host/root
|
||||||
|
mountPropagation: HostToContainer
|
||||||
|
readOnly: true
|
||||||
|
volumes:
|
||||||
|
- name: proc
|
||||||
|
hostPath: { path: /proc }
|
||||||
|
- name: sys
|
||||||
|
hostPath: { path: /sys }
|
||||||
|
- name: root
|
||||||
|
hostPath: { path: / }
|
||||||
|
tolerations:
|
||||||
|
- effect: NoSchedule
|
||||||
|
operator: Exists
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: node-exporter
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: node-exporter
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: node-exporter
|
||||||
|
ports:
|
||||||
|
- name: metrics
|
||||||
|
port: 9101
|
||||||
|
targetPort: metrics
|
||||||
150
infra/prod/k8s/monitoring/05-alertmanager.yaml
Normal file
150
infra/prod/k8s/monitoring/05-alertmanager.yaml
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Alertmanager -- routage des alertes vers Discord
|
||||||
|
# =============================================================================
|
||||||
|
# L'URL du webhook n'est PAS dans ce ConfigMap : elle est lue depuis un fichier
|
||||||
|
# monte depuis un Secret (webhook_url_file). Un ConfigMap est lisible par tout
|
||||||
|
# ce qui a un acces lecture au namespace, un webhook Discord permet de publier
|
||||||
|
# n'importe quoi dans votre canal d'exploitation.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: alertmanager-config
|
||||||
|
namespace: monitoring
|
||||||
|
data:
|
||||||
|
alertmanager.yml: |
|
||||||
|
global:
|
||||||
|
resolve_timeout: 5m
|
||||||
|
|
||||||
|
route:
|
||||||
|
receiver: discord
|
||||||
|
group_by: ['alertname', 'severity']
|
||||||
|
group_wait: 30s
|
||||||
|
group_interval: 5m
|
||||||
|
# Une alerte critique non traitee est repetee toutes les heures : elle ne
|
||||||
|
# doit pas disparaitre du fil de discussion.
|
||||||
|
repeat_interval: 1h
|
||||||
|
routes:
|
||||||
|
- matchers:
|
||||||
|
- severity = "avertissement"
|
||||||
|
receiver: discord
|
||||||
|
repeat_interval: 12h
|
||||||
|
|
||||||
|
inhibit_rules:
|
||||||
|
# Si le backend est totalement indisponible, inutile d'inonder le canal
|
||||||
|
# avec les alertes de latence et de taux d'erreur qui en decoulent.
|
||||||
|
- source_matchers:
|
||||||
|
- alertname = "BackendIndisponible"
|
||||||
|
target_matchers:
|
||||||
|
- severity =~ "avertissement|critique"
|
||||||
|
equal: ['cluster']
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: discord
|
||||||
|
discord_configs:
|
||||||
|
- webhook_url_file: /etc/alertmanager/secrets/discord-webhook
|
||||||
|
send_resolved: true
|
||||||
|
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}'
|
||||||
|
message: |-
|
||||||
|
{{ range .Alerts }}{{ .Annotations.summary }}
|
||||||
|
{{ .Annotations.description }}
|
||||||
|
{{ end }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: alertmanager-data
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: alertmanager
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: alertmanager
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: alertmanager
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: alertmanager
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65534
|
||||||
|
runAsGroup: 65534
|
||||||
|
fsGroup: 65534
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: alertmanager
|
||||||
|
image: prom/alertmanager:v0.28.0
|
||||||
|
args:
|
||||||
|
- --config.file=/etc/alertmanager/alertmanager.yml
|
||||||
|
- --storage.path=/alertmanager
|
||||||
|
- --web.listen-address=:9093
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 9093
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /-/ready
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 20m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 192Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /etc/alertmanager/alertmanager.yml
|
||||||
|
subPath: alertmanager.yml
|
||||||
|
- name: secrets
|
||||||
|
mountPath: /etc/alertmanager/secrets
|
||||||
|
readOnly: true
|
||||||
|
- name: data
|
||||||
|
mountPath: /alertmanager
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: alertmanager-config
|
||||||
|
- name: secrets
|
||||||
|
secret:
|
||||||
|
secretName: alertmanager-secrets
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: alertmanager-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: alertmanager
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: alertmanager
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 9093
|
||||||
|
targetPort: http
|
||||||
219
infra/prod/k8s/monitoring/06-grafana.yaml
Normal file
219
infra/prod/k8s/monitoring/06-grafana.yaml
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Grafana -- tableaux de bord (logs Loki + metriques Prometheus)
|
||||||
|
# =============================================================================
|
||||||
|
# Expose sur grafana.xpeditis.com, protege par TROIS couches :
|
||||||
|
# 1. le firewall Hetzner (seules les IP Cloudflare atteignent le serveur)
|
||||||
|
# 2. le middleware Traefik admin-ip-allowlist (vos IP d'administration)
|
||||||
|
# 3. l'authentification Grafana (Secret grafana-admin)
|
||||||
|
# L'inscription et l'acces anonyme sont desactives.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-provisioning
|
||||||
|
namespace: monitoring
|
||||||
|
data:
|
||||||
|
datasources.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: Loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://loki:3100
|
||||||
|
isDefault: false
|
||||||
|
jsonData:
|
||||||
|
maxLines: 2000
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
jsonData:
|
||||||
|
timeInterval: 30s
|
||||||
|
|
||||||
|
dashboards.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: xpeditis
|
||||||
|
orgId: 1
|
||||||
|
folder: Xpeditis
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 60
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: grafana-data
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: monitoring
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: grafana
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: grafana
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: grafana
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 472
|
||||||
|
runAsGroup: 472
|
||||||
|
fsGroup: 472
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: grafana
|
||||||
|
image: grafana/grafana:11.4.0
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: GF_SECURITY_ADMIN_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: grafana-admin
|
||||||
|
key: admin-user
|
||||||
|
- name: GF_SECURITY_ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: grafana-admin
|
||||||
|
key: admin-password
|
||||||
|
- name: GF_SERVER_ROOT_URL
|
||||||
|
value: "https://grafana.xpeditis.com"
|
||||||
|
- name: GF_USERS_ALLOW_SIGN_UP
|
||||||
|
value: "false"
|
||||||
|
- name: GF_AUTH_ANONYMOUS_ENABLED
|
||||||
|
value: "false"
|
||||||
|
# Empeche l'integration de Grafana dans une iframe tierce.
|
||||||
|
- name: GF_SECURITY_ALLOW_EMBEDDING
|
||||||
|
value: "false"
|
||||||
|
- name: GF_SECURITY_COOKIE_SECURE
|
||||||
|
value: "true"
|
||||||
|
- name: GF_SECURITY_COOKIE_SAMESITE
|
||||||
|
value: "strict"
|
||||||
|
- name: GF_SECURITY_STRICT_TRANSPORT_SECURITY
|
||||||
|
value: "true"
|
||||||
|
- name: GF_ANALYTICS_REPORTING_ENABLED
|
||||||
|
value: "false"
|
||||||
|
- name: GF_ANALYTICS_CHECK_FOR_UPDATES
|
||||||
|
value: "false"
|
||||||
|
# Les alertes sont evaluees par Prometheus et routees par
|
||||||
|
# Alertmanager : l'alerting Grafana ferait doublon.
|
||||||
|
- name: GF_UNIFIED_ALERTING_ENABLED
|
||||||
|
value: "false"
|
||||||
|
- name: GF_ALERTING_ENABLED
|
||||||
|
value: "false"
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 20
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: provisioning-datasources
|
||||||
|
mountPath: /etc/grafana/provisioning/datasources
|
||||||
|
- name: provisioning-dashboards
|
||||||
|
mountPath: /etc/grafana/provisioning/dashboards
|
||||||
|
- name: dashboards
|
||||||
|
mountPath: /var/lib/grafana/dashboards
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/grafana
|
||||||
|
volumes:
|
||||||
|
- name: provisioning-datasources
|
||||||
|
configMap:
|
||||||
|
name: grafana-provisioning
|
||||||
|
items:
|
||||||
|
- key: datasources.yaml
|
||||||
|
path: datasources.yaml
|
||||||
|
- name: provisioning-dashboards
|
||||||
|
configMap:
|
||||||
|
name: grafana-provisioning
|
||||||
|
items:
|
||||||
|
- key: dashboards.yaml
|
||||||
|
path: dashboards.yaml
|
||||||
|
# Cree par scripts/deploy-monitoring.sh depuis infra/logging/grafana/.
|
||||||
|
- name: dashboards
|
||||||
|
configMap:
|
||||||
|
name: grafana-dashboards
|
||||||
|
optional: true
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: grafana-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: monitoring
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: grafana
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 3000
|
||||||
|
targetPort: http
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: monitoring
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
|
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||||
|
traefik.ingress.kubernetes.io/router.middlewares: monitoring-admin-ip-allowlist@kubernetescrd,monitoring-security-headers@kubernetescrd
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- grafana.xpeditis.com
|
||||||
|
secretName: grafana-tls
|
||||||
|
rules:
|
||||||
|
- host: grafana.xpeditis.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
227
infra/prod/scripts/00-bootstrap-common.sh
Executable file
227
infra/prod/scripts/00-bootstrap-common.sh
Executable file
@ -0,0 +1,227 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# 00 - Durcissement commun aux deux noeuds (app-01 et db-01)
|
||||||
|
# =============================================================================
|
||||||
|
# A executer EN PREMIER sur chaque serveur, en sudo :
|
||||||
|
# scp infra/prod/scripts/00-bootstrap-common.sh deploy@<ip>:/tmp/
|
||||||
|
# ssh deploy@<ip> 'sudo bash /tmp/00-bootstrap-common.sh <role>'
|
||||||
|
#
|
||||||
|
# <role> = app | data
|
||||||
|
#
|
||||||
|
# Idempotent : peut etre relance sans risque.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROLE="${1:-}"
|
||||||
|
if [[ "$ROLE" != "app" && "$ROLE" != "data" ]]; then
|
||||||
|
echo "Usage: $0 <app|data>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$EUID" -ne 0 ]]; then
|
||||||
|
echo "Ce script doit tourner en root (sudo)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log() { printf '\n>>> %s\n' "$*"; }
|
||||||
|
|
||||||
|
# --- 1. Paquets de base ------------------------------------------------------
|
||||||
|
log "Mise a jour du systeme"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get upgrade -y -qq
|
||||||
|
apt-get install -y -qq \
|
||||||
|
ufw fail2ban unattended-upgrades apt-listchanges \
|
||||||
|
chrony auditd audispd-plugins \
|
||||||
|
curl gnupg ca-certificates jq git rsync htop ncdu \
|
||||||
|
needrestart
|
||||||
|
|
||||||
|
# --- 2. Mises a jour de securite automatiques --------------------------------
|
||||||
|
log "Activation des mises a jour de securite automatiques"
|
||||||
|
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'CONF'
|
||||||
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
|
APT::Periodic::AutocleanInterval "7";
|
||||||
|
CONF
|
||||||
|
|
||||||
|
cat > /etc/apt/apt.conf.d/50unattended-upgrades <<'CONF'
|
||||||
|
Unattended-Upgrade::Allowed-Origins {
|
||||||
|
"${distro_id}:${distro_codename}-security";
|
||||||
|
"${distro_id}ESMApps:${distro_codename}-apps-security";
|
||||||
|
"${distro_id}ESM:${distro_codename}-infra-security";
|
||||||
|
};
|
||||||
|
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
|
||||||
|
Unattended-Upgrade::Remove-Unused-Dependencies "true";
|
||||||
|
// Redemarrage automatique la nuit SI un paquet noyau l'exige.
|
||||||
|
// Fenetre choisie hors des heures ouvrees des transitaires europeens.
|
||||||
|
Unattended-Upgrade::Automatic-Reboot "true";
|
||||||
|
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
|
||||||
|
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
|
||||||
|
Unattended-Upgrade::Mail "";
|
||||||
|
CONF
|
||||||
|
|
||||||
|
systemctl enable --now unattended-upgrades
|
||||||
|
|
||||||
|
# --- 3. SSH ------------------------------------------------------------------
|
||||||
|
log "Durcissement SSH"
|
||||||
|
cat > /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf <<'CONF'
|
||||||
|
# Authentification par cle uniquement.
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
ChallengeResponseAuthentication no
|
||||||
|
PermitEmptyPasswords no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
AuthenticationMethods publickey
|
||||||
|
|
||||||
|
# Reduction de surface.
|
||||||
|
X11Forwarding no
|
||||||
|
AllowAgentForwarding no
|
||||||
|
PermitTunnel no
|
||||||
|
GatewayPorts no
|
||||||
|
|
||||||
|
# Anti brute-force / sessions fantomes.
|
||||||
|
MaxAuthTries 3
|
||||||
|
MaxSessions 5
|
||||||
|
LoginGraceTime 20
|
||||||
|
ClientAliveInterval 300
|
||||||
|
ClientAliveCountMax 2
|
||||||
|
|
||||||
|
# Cryptographie moderne uniquement.
|
||||||
|
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512
|
||||||
|
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
||||||
|
Macs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
|
||||||
|
|
||||||
|
AllowUsers deploy
|
||||||
|
CONF
|
||||||
|
|
||||||
|
# Retire les cles d'hote faibles si presentes.
|
||||||
|
rm -f /etc/ssh/ssh_host_dsa_key* /etc/ssh/ssh_host_ecdsa_key* || true
|
||||||
|
|
||||||
|
sshd -t
|
||||||
|
systemctl restart ssh 2>/dev/null || systemctl restart sshd
|
||||||
|
|
||||||
|
# --- 4. fail2ban -------------------------------------------------------------
|
||||||
|
log "Configuration fail2ban"
|
||||||
|
cat > /etc/fail2ban/jail.d/xpeditis.local <<'CONF'
|
||||||
|
[DEFAULT]
|
||||||
|
bantime = 1h
|
||||||
|
findtime = 10m
|
||||||
|
maxretry = 4
|
||||||
|
backend = systemd
|
||||||
|
# Ne jamais se bannir soi-meme depuis le reseau prive.
|
||||||
|
ignoreip = 127.0.0.1/8 ::1 10.10.0.0/16
|
||||||
|
|
||||||
|
[sshd]
|
||||||
|
enabled = true
|
||||||
|
mode = aggressive
|
||||||
|
maxretry = 3
|
||||||
|
bantime = 24h
|
||||||
|
CONF
|
||||||
|
|
||||||
|
systemctl enable --now fail2ban
|
||||||
|
systemctl restart fail2ban
|
||||||
|
|
||||||
|
# --- 5. Parametres noyau -----------------------------------------------------
|
||||||
|
log "Durcissement sysctl"
|
||||||
|
cat > /etc/sysctl.d/99-xpeditis-hardening.conf <<'CONF'
|
||||||
|
# Reseau
|
||||||
|
net.ipv4.conf.all.rp_filter = 1
|
||||||
|
net.ipv4.conf.default.rp_filter = 1
|
||||||
|
net.ipv4.conf.all.accept_redirects = 0
|
||||||
|
net.ipv4.conf.all.send_redirects = 0
|
||||||
|
net.ipv4.conf.all.accept_source_route = 0
|
||||||
|
net.ipv4.conf.all.log_martians = 1
|
||||||
|
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||||
|
net.ipv4.tcp_syncookies = 1
|
||||||
|
net.ipv6.conf.all.accept_redirects = 0
|
||||||
|
net.ipv6.conf.all.accept_source_route = 0
|
||||||
|
|
||||||
|
# Memoire / noyau
|
||||||
|
kernel.randomize_va_space = 2
|
||||||
|
kernel.kptr_restrict = 2
|
||||||
|
kernel.dmesg_restrict = 1
|
||||||
|
kernel.yama.ptrace_scope = 1
|
||||||
|
fs.protected_hardlinks = 1
|
||||||
|
fs.protected_symlinks = 1
|
||||||
|
fs.suid_dumpable = 0
|
||||||
|
|
||||||
|
# Capacite : k3s et PostgreSQL ouvrent beaucoup de descripteurs.
|
||||||
|
fs.file-max = 2097152
|
||||||
|
fs.inotify.max_user_instances = 8192
|
||||||
|
fs.inotify.max_user_watches = 524288
|
||||||
|
CONF
|
||||||
|
sysctl --system >/dev/null
|
||||||
|
|
||||||
|
# --- 6. Journalisation -------------------------------------------------------
|
||||||
|
log "Limitation des journaux systemd (evite de saturer le disque)"
|
||||||
|
mkdir -p /etc/systemd/journald.conf.d
|
||||||
|
cat > /etc/systemd/journald.conf.d/99-xpeditis.conf <<'CONF'
|
||||||
|
[Journal]
|
||||||
|
SystemMaxUse=2G
|
||||||
|
SystemMaxFileSize=200M
|
||||||
|
MaxRetentionSec=30day
|
||||||
|
Compress=yes
|
||||||
|
CONF
|
||||||
|
systemctl restart systemd-journald
|
||||||
|
|
||||||
|
# --- 7. Horloge --------------------------------------------------------------
|
||||||
|
log "Synchronisation horaire (obligatoire : JWT, TLS, audit_logs)"
|
||||||
|
timedatectl set-timezone Europe/Paris
|
||||||
|
systemctl enable --now chrony
|
||||||
|
|
||||||
|
# --- 8. Audit ----------------------------------------------------------------
|
||||||
|
log "Regles auditd minimales"
|
||||||
|
cat > /etc/audit/rules.d/99-xpeditis.rules <<'CONF'
|
||||||
|
-w /etc/passwd -p wa -k identity
|
||||||
|
-w /etc/shadow -p wa -k identity
|
||||||
|
-w /etc/ssh/sshd_config -p wa -k sshd
|
||||||
|
-w /etc/ssh/sshd_config.d/ -p wa -k sshd
|
||||||
|
-w /etc/sudoers -p wa -k sudoers
|
||||||
|
-w /etc/sudoers.d/ -p wa -k sudoers
|
||||||
|
-w /var/log/auth.log -p wa -k authlog
|
||||||
|
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=4294967295 -k rootcmd
|
||||||
|
CONF
|
||||||
|
augenrules --load >/dev/null 2>&1 || true
|
||||||
|
systemctl enable --now auditd
|
||||||
|
|
||||||
|
# --- 9. Pare-feu local -------------------------------------------------------
|
||||||
|
# Le firewall Hetzner Cloud ne filtre QUE les interfaces publiques. UFW prend
|
||||||
|
# en charge le reseau prive, ou transite le trafic PostgreSQL/Redis.
|
||||||
|
log "Configuration UFW (role: $ROLE)"
|
||||||
|
ufw --force reset >/dev/null
|
||||||
|
ufw default deny incoming
|
||||||
|
ufw default allow outgoing
|
||||||
|
ufw allow 22/tcp comment 'SSH'
|
||||||
|
|
||||||
|
if [[ "$ROLE" == "app" ]]; then
|
||||||
|
ufw allow 80/tcp comment 'HTTP (redirection + ACME)'
|
||||||
|
ufw allow 443/tcp comment 'HTTPS'
|
||||||
|
ufw allow 6443/tcp comment 'API k3s'
|
||||||
|
# Reseau prive : le noeud app doit joindre db-01, pas l'inverse.
|
||||||
|
ufw allow from 10.10.0.0/16 to any port 10250 proto tcp comment 'kubelet metrics'
|
||||||
|
else
|
||||||
|
# Seul app-01 (IP privee) peut atteindre PostgreSQL et Redis.
|
||||||
|
APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}"
|
||||||
|
ufw allow from "${APP_PRIVATE_IP}" to any port 5432 proto tcp comment 'PostgreSQL <- app-01'
|
||||||
|
ufw allow from "${APP_PRIVATE_IP}" to any port 6379 proto tcp comment 'Redis <- app-01'
|
||||||
|
fi
|
||||||
|
|
||||||
|
ufw --force enable
|
||||||
|
ufw status verbose
|
||||||
|
|
||||||
|
# --- 10. Verifications finales ----------------------------------------------
|
||||||
|
log "Verifications"
|
||||||
|
echo " SSH root login : $(sshd -T 2>/dev/null | grep -i '^permitrootlogin' || echo '?')"
|
||||||
|
echo " Password auth : $(sshd -T 2>/dev/null | grep -i '^passwordauthentication' || echo '?')"
|
||||||
|
echo " fail2ban : $(systemctl is-active fail2ban)"
|
||||||
|
echo " unattended-upgr. : $(systemctl is-active unattended-upgrades)"
|
||||||
|
echo " auditd : $(systemctl is-active auditd)"
|
||||||
|
echo " chrony : $(systemctl is-active chrony)"
|
||||||
|
|
||||||
|
log "Durcissement commun termine pour le role '$ROLE'."
|
||||||
|
echo "Etape suivante :"
|
||||||
|
if [[ "$ROLE" == "app" ]]; then
|
||||||
|
echo " sudo bash 02-setup-k3s-server.sh"
|
||||||
|
else
|
||||||
|
echo " sudo bash 01-setup-data-node.sh"
|
||||||
|
fi
|
||||||
170
infra/prod/scripts/01-setup-data-node.sh
Executable file
170
infra/prod/scripts/01-setup-data-node.sh
Executable file
@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# 01 - Installation du noeud de donnees (db-01)
|
||||||
|
# =============================================================================
|
||||||
|
# Prerequis : 00-bootstrap-common.sh data deja execute sur ce serveur.
|
||||||
|
#
|
||||||
|
# sudo bash 01-setup-data-node.sh
|
||||||
|
#
|
||||||
|
# Ce script :
|
||||||
|
# 1. monte le volume Hetzner sur /var/lib/xpeditis/pgdata
|
||||||
|
# 2. installe Docker Engine depuis le depot officiel
|
||||||
|
# 3. genere le certificat TLS interne de PostgreSQL
|
||||||
|
# 4. installe age (chiffrement des dumps)
|
||||||
|
# 5. installe les unites systemd de sauvegarde
|
||||||
|
#
|
||||||
|
# Il ne demarre PAS la base : il faut d'abord deposer .env.data (SOPS).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; }
|
||||||
|
|
||||||
|
APP_PRIVATE_IP="${APP_PRIVATE_IP:-10.10.1.10}"
|
||||||
|
DB_PRIVATE_IP="${DB_PRIVATE_IP:-10.10.1.20}"
|
||||||
|
BASE_DIR=/opt/xpeditis/data-node
|
||||||
|
DATA_ROOT=/var/lib/xpeditis
|
||||||
|
|
||||||
|
log() { printf '\n>>> %s\n' "$*"; }
|
||||||
|
|
||||||
|
# --- 1. Volume de donnees ----------------------------------------------------
|
||||||
|
log "Montage du volume PostgreSQL"
|
||||||
|
mkdir -p "${DATA_ROOT}"/{pgdata,redis,certs,dumps}
|
||||||
|
|
||||||
|
# Hetzner expose les volumes sous /dev/disk/by-id/scsi-0HC_Volume_<id>.
|
||||||
|
VOLUME_DEV="$(ls /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | head -1 || true)"
|
||||||
|
|
||||||
|
if [[ -n "$VOLUME_DEV" ]]; then
|
||||||
|
if ! blkid "$VOLUME_DEV" >/dev/null 2>&1; then
|
||||||
|
log "Formatage de $VOLUME_DEV en ext4 (volume vierge)"
|
||||||
|
mkfs.ext4 -F -L xpeditis-pgdata "$VOLUME_DEV"
|
||||||
|
else
|
||||||
|
log "Volume deja formate, conservation des donnees existantes"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "${DATA_ROOT}/pgdata" /etc/fstab; then
|
||||||
|
# `nofail` : si le volume est absent au boot, le serveur demarre quand meme
|
||||||
|
# et on diagnostique en SSH plutot que de se retrouver hors ligne.
|
||||||
|
echo "${VOLUME_DEV} ${DATA_ROOT}/pgdata ext4 discard,nofail,defaults 0 2" >> /etc/fstab
|
||||||
|
fi
|
||||||
|
mount -a
|
||||||
|
df -h "${DATA_ROOT}/pgdata"
|
||||||
|
else
|
||||||
|
echo "AVERTISSEMENT: aucun volume Hetzner detecte. Les donnees resteront sur le disque systeme."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# L'UID/GID 999 est celui de l'utilisateur postgres dans l'image officielle.
|
||||||
|
chown -R 999:999 "${DATA_ROOT}/pgdata" "${DATA_ROOT}/redis" "${DATA_ROOT}/dumps"
|
||||||
|
chmod 700 "${DATA_ROOT}/pgdata"
|
||||||
|
|
||||||
|
# --- 2. Docker Engine --------------------------------------------------------
|
||||||
|
if ! command -v docker >/dev/null; then
|
||||||
|
log "Installation de Docker Engine (depot officiel)"
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||||
|
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||||
|
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
|
||||||
|
> /etc/apt/sources.list.d/docker.list
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Durcissement du demon Docker"
|
||||||
|
cat > /etc/docker/daemon.json <<'JSON'
|
||||||
|
{
|
||||||
|
"log-driver": "json-file",
|
||||||
|
"log-opts": { "max-size": "50m", "max-file": "5" },
|
||||||
|
"live-restore": true,
|
||||||
|
"userland-proxy": false,
|
||||||
|
"no-new-privileges": true,
|
||||||
|
"icc": false,
|
||||||
|
"default-address-pools": [
|
||||||
|
{ "base": "172.28.0.0/16", "size": 24 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
systemctl enable --now docker
|
||||||
|
systemctl restart docker
|
||||||
|
|
||||||
|
# --- 3. Certificat TLS de PostgreSQL ----------------------------------------
|
||||||
|
# Certificat auto-signe : PostgreSQL n'est joignable que depuis app-01 sur un
|
||||||
|
# reseau prive, il n'y a pas de tiers a authentifier. Ce certificat sert a
|
||||||
|
# CHIFFRER le transport, pas a prouver une identite publique.
|
||||||
|
# Cote client, DATABASE_SSL=true avec rejectUnauthorized:false accepte ce
|
||||||
|
# certificat : c'est coherent, et documente dans 04-noeud-donnees.md.
|
||||||
|
if [[ ! -f "${DATA_ROOT}/certs/server.key" ]]; then
|
||||||
|
log "Generation du certificat TLS PostgreSQL (10 ans)"
|
||||||
|
openssl req -new -x509 -days 3650 -nodes \
|
||||||
|
-newkey rsa:4096 \
|
||||||
|
-out "${DATA_ROOT}/certs/server.crt" \
|
||||||
|
-keyout "${DATA_ROOT}/certs/server.key" \
|
||||||
|
-subj "/CN=xpeditis-prod-db-01/O=Xpeditis/C=FR" \
|
||||||
|
-addext "subjectAltName=IP:${DB_PRIVATE_IP},DNS:postgres"
|
||||||
|
# PostgreSQL refuse de demarrer si la cle privee est lisible par d'autres.
|
||||||
|
chown 999:999 "${DATA_ROOT}/certs/server.key" "${DATA_ROOT}/certs/server.crt"
|
||||||
|
chmod 600 "${DATA_ROOT}/certs/server.key"
|
||||||
|
chmod 644 "${DATA_ROOT}/certs/server.crt"
|
||||||
|
else
|
||||||
|
log "Certificat TLS deja present, conserve"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. age (chiffrement des dumps) -----------------------------------------
|
||||||
|
if ! command -v age >/dev/null; then
|
||||||
|
log "Installation de age"
|
||||||
|
apt-get install -y -qq age
|
||||||
|
fi
|
||||||
|
mkdir -p /root/.config/xpeditis
|
||||||
|
chmod 700 /root/.config/xpeditis
|
||||||
|
|
||||||
|
# --- 5. Arborescence applicative --------------------------------------------
|
||||||
|
log "Preparation de ${BASE_DIR}"
|
||||||
|
mkdir -p "${BASE_DIR}"/{conf,backup}
|
||||||
|
chmod 750 "${BASE_DIR}"
|
||||||
|
|
||||||
|
# --- 6. Unites systemd de sauvegarde ----------------------------------------
|
||||||
|
if [[ -f "${BASE_DIR}/backup/xpeditis-backup.service" ]]; then
|
||||||
|
log "Installation des timers de sauvegarde"
|
||||||
|
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.service" /etc/systemd/system/
|
||||||
|
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup.timer" /etc/systemd/system/
|
||||||
|
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.service" /etc/systemd/system/
|
||||||
|
install -m 0644 "${BASE_DIR}/backup/xpeditis-backup-verify.timer" /etc/systemd/system/
|
||||||
|
chmod +x "${BASE_DIR}/backup/"*.sh
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now xpeditis-backup.timer xpeditis-backup-verify.timer
|
||||||
|
systemctl list-timers 'xpeditis-*' --no-pager
|
||||||
|
else
|
||||||
|
echo "AVERTISSEMENT: ${BASE_DIR}/backup/ vide. Copiez infra/prod/data-node/ puis relancez ce script."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 7. Rappel du filtrage reseau -------------------------------------------
|
||||||
|
log "Regles UFW effectives"
|
||||||
|
ufw status numbered
|
||||||
|
|
||||||
|
cat <<NEXT
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
|
Noeud de donnees pret.
|
||||||
|
=============================================================================
|
||||||
|
Etapes suivantes (depuis votre poste) :
|
||||||
|
|
||||||
|
1. Copier la configuration :
|
||||||
|
rsync -a infra/prod/data-node/ deploy@${DB_PRIVATE_IP}:/tmp/data-node/
|
||||||
|
ssh deploy@<ip-publique-db> 'sudo rsync -a /tmp/data-node/ ${BASE_DIR}/'
|
||||||
|
|
||||||
|
2. Dechiffrer et deposer les secrets :
|
||||||
|
sops -d --input-type dotenv --output-type dotenv \\
|
||||||
|
infra/prod/data-node/data-node.sops.env > /tmp/.env.data
|
||||||
|
scp /tmp/.env.data deploy@<ip-publique-db>:/tmp/
|
||||||
|
ssh deploy@<ip> 'sudo install -m600 -o root -g root /tmp/.env.data ${BASE_DIR}/.env.data && shred -u /tmp/.env.data'
|
||||||
|
|
||||||
|
3. Demarrer :
|
||||||
|
cd ${BASE_DIR} && sudo docker compose -f docker-compose.data.yml --env-file .env.data up -d --build
|
||||||
|
|
||||||
|
4. Premiere sauvegarde complete (obligatoire avant d'ouvrir au public) :
|
||||||
|
sudo systemctl start xpeditis-backup.service
|
||||||
|
sudo journalctl -u xpeditis-backup -f
|
||||||
|
|
||||||
|
Verifier que rien n'est expose publiquement, depuis un autre reseau :
|
||||||
|
nmap -Pn -p 5432,6379 <ip-publique-db> # doit repondre filtered
|
||||||
|
=============================================================================
|
||||||
|
NEXT
|
||||||
245
infra/prod/scripts/02-setup-k3s-server.sh
Executable file
245
infra/prod/scripts/02-setup-k3s-server.sh
Executable file
@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# 02 - Installation du cluster k3s (app-01)
|
||||||
|
# =============================================================================
|
||||||
|
# Prerequis : 00-bootstrap-common.sh app deja execute sur ce serveur.
|
||||||
|
#
|
||||||
|
# sudo K3S_VERSION=v1.31.5+k3s1 PUBLIC_IP=<ip> bash 02-setup-k3s-server.sh
|
||||||
|
#
|
||||||
|
# Choix structurants et pourquoi :
|
||||||
|
# --secrets-encryption les Secrets sont chiffres au repos dans la base
|
||||||
|
# d'etat de k3s. Sans ce drapeau, un acces disque
|
||||||
|
# (snapshot, vol de volume) livre tous les secrets.
|
||||||
|
# --protect-kernel-defaults refuse de demarrer si les sysctl attendus par
|
||||||
|
# kubelet ne sont pas poses : echec bruyant plutot
|
||||||
|
# que derive silencieuse.
|
||||||
|
# audit-log journal d'audit de l'API : indispensable pour
|
||||||
|
# repondre a "qui a supprime ce deploiement".
|
||||||
|
# Traefik est CONSERVE (celui livre par k3s) et reconfigure via
|
||||||
|
# HelmChartConfig : entrypoints, en-tetes de
|
||||||
|
# securite, IPs de confiance Cloudflare.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[[ "$EUID" -eq 0 ]] || { echo "Executer en root (sudo)." >&2; exit 1; }
|
||||||
|
|
||||||
|
K3S_VERSION="${K3S_VERSION:-v1.31.5+k3s1}"
|
||||||
|
PUBLIC_IP="${PUBLIC_IP:-$(curl -s --max-time 5 https://ifconfig.me || true)}"
|
||||||
|
PRIVATE_IP="${PRIVATE_IP:-10.10.1.10}"
|
||||||
|
MANIFEST_DIR=/var/lib/rancher/k3s/server/manifests
|
||||||
|
|
||||||
|
log() { printf '\n>>> %s\n' "$*"; }
|
||||||
|
|
||||||
|
[[ -n "$PUBLIC_IP" ]] || { echo "PUBLIC_IP introuvable, passez-la en variable." >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- 1. Prerequis noyau ------------------------------------------------------
|
||||||
|
log "Parametres noyau exiges par kubelet (--protect-kernel-defaults)"
|
||||||
|
cat > /etc/sysctl.d/90-kubelet.conf <<'CONF'
|
||||||
|
vm.panic_on_oom=0
|
||||||
|
vm.overcommit_memory=1
|
||||||
|
kernel.panic=10
|
||||||
|
kernel.panic_on_oops=1
|
||||||
|
CONF
|
||||||
|
sysctl --system >/dev/null
|
||||||
|
|
||||||
|
log "Desactivation du swap (exige par kubelet)"
|
||||||
|
swapoff -a || true
|
||||||
|
sed -i '/\sswap\s/s/^/#/' /etc/fstab || true
|
||||||
|
|
||||||
|
# --- 2. Politique d'audit de l'API Kubernetes -------------------------------
|
||||||
|
log "Politique d'audit"
|
||||||
|
mkdir -p /var/lib/rancher/k3s/server /var/log/k3s
|
||||||
|
cat > /var/lib/rancher/k3s/server/audit-policy.yaml <<'YAML'
|
||||||
|
apiVersion: audit.k8s.io/v1
|
||||||
|
kind: Policy
|
||||||
|
# Ne jamais journaliser le contenu des Secrets : le journal d'audit deviendrait
|
||||||
|
# lui-meme un coffre-fort en clair.
|
||||||
|
omitStages:
|
||||||
|
- RequestReceived
|
||||||
|
rules:
|
||||||
|
- level: None
|
||||||
|
resources:
|
||||||
|
- group: ""
|
||||||
|
resources: ["secrets", "configmaps"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
|
||||||
|
# Bruit de fond : sondes et lectures d'etat.
|
||||||
|
- level: None
|
||||||
|
users: ["system:kube-proxy", "system:apiserver"]
|
||||||
|
verbs: ["watch", "list"]
|
||||||
|
- level: None
|
||||||
|
nonResourceURLs: ["/healthz*", "/readyz*", "/livez*", "/version", "/metrics"]
|
||||||
|
|
||||||
|
# Toute ecriture est tracee avec ses metadonnees (qui, quoi, quand).
|
||||||
|
- level: Metadata
|
||||||
|
verbs: ["create", "update", "patch", "delete", "deletecollection"]
|
||||||
|
|
||||||
|
# Acces aux Secrets : trace, sans le contenu.
|
||||||
|
- level: Metadata
|
||||||
|
resources:
|
||||||
|
- group: ""
|
||||||
|
resources: ["secrets"]
|
||||||
|
|
||||||
|
# Escalades de privileges : trace complet cote requete.
|
||||||
|
- level: Request
|
||||||
|
resources:
|
||||||
|
- group: "rbac.authorization.k8s.io"
|
||||||
|
resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
|
||||||
|
|
||||||
|
- level: Metadata
|
||||||
|
YAML
|
||||||
|
chmod 600 /var/lib/rancher/k3s/server/audit-policy.yaml
|
||||||
|
|
||||||
|
# --- 3. Configuration Traefik (avant l'installation : k3s l'applique au boot) -
|
||||||
|
log "Configuration Traefik (HelmChartConfig)"
|
||||||
|
mkdir -p "$MANIFEST_DIR"
|
||||||
|
cat > "${MANIFEST_DIR}/traefik-config.yaml" <<'YAML'
|
||||||
|
apiVersion: helm.cattle.io/v1
|
||||||
|
kind: HelmChartConfig
|
||||||
|
metadata:
|
||||||
|
name: traefik
|
||||||
|
namespace: kube-system
|
||||||
|
spec:
|
||||||
|
valuesContent: |-
|
||||||
|
# Les vraies IP des visiteurs arrivent dans X-Forwarded-For, pose par
|
||||||
|
# Cloudflare. Sans cette liste de confiance, la limitation de debit et les
|
||||||
|
# audit_logs verraient tous l'IP de Cloudflare : inexploitable.
|
||||||
|
# Rafraichir avec scripts/refresh-cloudflare-ips.sh.
|
||||||
|
additionalArguments:
|
||||||
|
- "--entrypoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32"
|
||||||
|
- "--entrypoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,2400:cb00::/32,2606:4700::/32,2803:f800::/32,2405:b500::/32,2405:8100::/32,2a06:98c0::/29,2c0f:f248::/32"
|
||||||
|
# Tout le trafic HTTP est redirige en HTTPS au niveau de l'entrypoint :
|
||||||
|
# aucun Ingress ne peut oublier de le faire.
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
|
||||||
|
- "--entrypoints.web.http.redirections.entryPoint.permanent=true"
|
||||||
|
# Delais de garde : borne les connexions lentes (slowloris).
|
||||||
|
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=60s"
|
||||||
|
- "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0s"
|
||||||
|
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=180s"
|
||||||
|
- "--serversTransport.maxIdleConnsPerHost=100"
|
||||||
|
# WebSocket (Socket.IO) : pas de timeout d'ecriture, sinon les
|
||||||
|
# notifications temps reel sont coupees toutes les 60 s.
|
||||||
|
- "--metrics.prometheus=true"
|
||||||
|
- "--metrics.prometheus.addEntryPointsLabels=true"
|
||||||
|
- "--metrics.prometheus.addServicesLabels=true"
|
||||||
|
- "--accesslog=true"
|
||||||
|
- "--accesslog.format=json"
|
||||||
|
- "--accesslog.fields.headers.defaultMode=drop"
|
||||||
|
- "--accesslog.fields.headers.names.User-Agent=keep"
|
||||||
|
- "--accesslog.fields.headers.names.Cf-Connecting-Ip=keep"
|
||||||
|
- "--ping=true"
|
||||||
|
|
||||||
|
# Le tableau de bord Traefik n'est jamais expose.
|
||||||
|
ingressRoute:
|
||||||
|
dashboard:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
logs:
|
||||||
|
general:
|
||||||
|
level: WARN
|
||||||
|
access:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Le service est en LoadBalancer (klipper-lb de k3s) : il ecoute
|
||||||
|
# directement sur les ports 80/443 de l'hote, filtres par le firewall
|
||||||
|
# Hetzner qui n'accepte que les IP Cloudflare.
|
||||||
|
service:
|
||||||
|
spec:
|
||||||
|
externalTrafficPolicy: Local
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
YAML
|
||||||
|
|
||||||
|
# --- 4. Installation de k3s --------------------------------------------------
|
||||||
|
if ! command -v k3s >/dev/null; then
|
||||||
|
log "Installation de k3s ${K3S_VERSION}"
|
||||||
|
curl -sfL https://get.k3s.io | \
|
||||||
|
INSTALL_K3S_VERSION="${K3S_VERSION}" \
|
||||||
|
INSTALL_K3S_EXEC="server \
|
||||||
|
--node-name=xpeditis-prod-app-01 \
|
||||||
|
--node-ip=${PRIVATE_IP} \
|
||||||
|
--advertise-address=${PRIVATE_IP} \
|
||||||
|
--tls-san=${PUBLIC_IP} \
|
||||||
|
--tls-san=${PRIVATE_IP} \
|
||||||
|
--write-kubeconfig-mode=0600 \
|
||||||
|
--secrets-encryption \
|
||||||
|
--protect-kernel-defaults \
|
||||||
|
--kube-apiserver-arg=audit-log-path=/var/log/k3s/audit.log \
|
||||||
|
--kube-apiserver-arg=audit-policy-file=/var/lib/rancher/k3s/server/audit-policy.yaml \
|
||||||
|
--kube-apiserver-arg=audit-log-maxage=30 \
|
||||||
|
--kube-apiserver-arg=audit-log-maxbackup=10 \
|
||||||
|
--kube-apiserver-arg=audit-log-maxsize=100 \
|
||||||
|
--kube-apiserver-arg=request-timeout=300s \
|
||||||
|
--kubelet-arg=streaming-connection-idle-timeout=5m \
|
||||||
|
--kubelet-arg=event-qps=0 \
|
||||||
|
--etcd-expose-metrics=false" \
|
||||||
|
sh -
|
||||||
|
else
|
||||||
|
log "k3s deja installe : $(k3s --version | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl enable --now k3s
|
||||||
|
log "Attente de la disponibilite du noeud"
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
k3s kubectl get node >/dev/null 2>&1 && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
k3s kubectl get node -o wide
|
||||||
|
|
||||||
|
# --- 5. kubectl pour l'utilisateur deploy -----------------------------------
|
||||||
|
log "Configuration de kubectl pour l'utilisateur deploy"
|
||||||
|
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||||
|
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/${DEPLOY_USER}/.kube"
|
||||||
|
install -m 600 -o "$DEPLOY_USER" -g "$DEPLOY_USER" \
|
||||||
|
/etc/rancher/k3s/k3s.yaml "/home/${DEPLOY_USER}/.kube/config"
|
||||||
|
grep -q 'KUBECONFIG' "/home/${DEPLOY_USER}/.bashrc" || \
|
||||||
|
echo 'export KUBECONFIG=$HOME/.kube/config' >> "/home/${DEPLOY_USER}/.bashrc"
|
||||||
|
|
||||||
|
# `k3s kubectl` reste reserve a root ; deploy passe par son kubeconfig.
|
||||||
|
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
|
||||||
|
|
||||||
|
# --- 6. Rotation des journaux d'audit ---------------------------------------
|
||||||
|
cat > /etc/logrotate.d/k3s-audit <<'CONF'
|
||||||
|
/var/log/k3s/audit.log {
|
||||||
|
daily
|
||||||
|
rotate 30
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
su root root
|
||||||
|
}
|
||||||
|
CONF
|
||||||
|
|
||||||
|
# --- 7. Verifications --------------------------------------------------------
|
||||||
|
log "Etat du cluster"
|
||||||
|
k3s kubectl get nodes
|
||||||
|
k3s kubectl -n kube-system get pods
|
||||||
|
|
||||||
|
cat <<NEXT
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
|
Cluster k3s pret.
|
||||||
|
=============================================================================
|
||||||
|
Le chiffrement des Secrets au repos est actif :
|
||||||
|
sudo k3s secrets-encrypt status
|
||||||
|
|
||||||
|
Recuperer le kubeconfig sur votre poste (l'API n'est ouverte qu'a vos IPs
|
||||||
|
d'administration, cf. firewall Terraform) :
|
||||||
|
|
||||||
|
ssh deploy@${PUBLIC_IP} 'cat ~/.kube/config' \\
|
||||||
|
| sed "s/127.0.0.1/${PUBLIC_IP}/" > ~/.kube/xpeditis-prod.yaml
|
||||||
|
chmod 600 ~/.kube/xpeditis-prod.yaml
|
||||||
|
export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
kubectl get nodes
|
||||||
|
|
||||||
|
Etape suivante :
|
||||||
|
sudo bash 03-install-cluster-addons.sh
|
||||||
|
=============================================================================
|
||||||
|
NEXT
|
||||||
86
infra/prod/scripts/03-install-cluster-addons.sh
Executable file
86
infra/prod/scripts/03-install-cluster-addons.sh
Executable file
@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# 03 - Composants du cluster (cert-manager, namespaces, acces registre)
|
||||||
|
# =============================================================================
|
||||||
|
# A executer depuis VOTRE POSTE, kubeconfig de prod charge :
|
||||||
|
#
|
||||||
|
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
# REGISTRY_TOKEN=... bash 03-install-cluster-addons.sh
|
||||||
|
#
|
||||||
|
# cert-manager est installe depuis son manifeste statique officiel : pas de
|
||||||
|
# Helm a maintenir sur le cluster, une seule version epinglee, un seul fichier
|
||||||
|
# a relire en cas de doute.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CERT_MANAGER_VERSION="${CERT_MANAGER_VERSION:-v1.16.2}"
|
||||||
|
REGISTRY_SERVER="${REGISTRY_SERVER:-rg.fr-par.scw.cloud}"
|
||||||
|
REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
|
||||||
|
NAMESPACE="${NAMESPACE:-xpeditis-prod}"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
K8S_DIR="${HERE}/../k8s"
|
||||||
|
|
||||||
|
log() { printf '\n>>> %s\n' "$*"; }
|
||||||
|
|
||||||
|
kubectl version --output=yaml >/dev/null || { echo "kubectl ne joint pas le cluster." >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- 1. Namespaces -----------------------------------------------------------
|
||||||
|
log "Namespaces"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
|
||||||
|
|
||||||
|
# --- 2. cert-manager ---------------------------------------------------------
|
||||||
|
if ! kubectl get ns cert-manager >/dev/null 2>&1; then
|
||||||
|
log "Installation de cert-manager ${CERT_MANAGER_VERSION}"
|
||||||
|
kubectl apply -f \
|
||||||
|
"https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml"
|
||||||
|
else
|
||||||
|
log "cert-manager deja present"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Attente de cert-manager"
|
||||||
|
kubectl -n cert-manager rollout status deploy/cert-manager --timeout=180s
|
||||||
|
kubectl -n cert-manager rollout status deploy/cert-manager-webhook --timeout=180s
|
||||||
|
kubectl -n cert-manager rollout status deploy/cert-manager-cainjector --timeout=180s
|
||||||
|
|
||||||
|
# --- 3. Acces au registre Scaleway ------------------------------------------
|
||||||
|
# Le registre est prive : sans ce Secret, les pods restent en ImagePullBackOff.
|
||||||
|
if [[ -n "$REGISTRY_TOKEN" ]]; then
|
||||||
|
log "Secret d'acces au registre (${REGISTRY_SERVER})"
|
||||||
|
kubectl -n "$NAMESPACE" create secret docker-registry regcred \
|
||||||
|
--docker-server="$REGISTRY_SERVER" \
|
||||||
|
--docker-username=nologin \
|
||||||
|
--docker-password="$REGISTRY_TOKEN" \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
else
|
||||||
|
echo "AVERTISSEMENT: REGISTRY_TOKEN absent. Creez 'regcred' avant de deployer :"
|
||||||
|
echo " kubectl -n ${NAMESPACE} create secret docker-registry regcred \\"
|
||||||
|
echo " --docker-server=${REGISTRY_SERVER} --docker-username=nologin --docker-password=<token>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. Emetteur ACME --------------------------------------------------------
|
||||||
|
# Le ClusterIssuer depend d'un Secret contenant le token API Cloudflare :
|
||||||
|
# il est chiffre SOPS et doit etre applique avant (secrets-apply.sh).
|
||||||
|
if kubectl -n cert-manager get secret cloudflare-api-token >/dev/null 2>&1; then
|
||||||
|
log "ClusterIssuer Let's Encrypt (DNS-01 Cloudflare)"
|
||||||
|
kubectl apply -f "${K8S_DIR}/cluster/cluster-issuer.yaml"
|
||||||
|
else
|
||||||
|
echo "AVERTISSEMENT: le Secret cert-manager/cloudflare-api-token est absent."
|
||||||
|
echo " Appliquez d'abord les secrets : bash scripts/secrets-apply.sh"
|
||||||
|
echo " puis : kubectl apply -f ${K8S_DIR}/cluster/cluster-issuer.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. Verifications --------------------------------------------------------
|
||||||
|
log "Etat"
|
||||||
|
kubectl get ns
|
||||||
|
kubectl -n cert-manager get pods
|
||||||
|
kubectl get clusterissuer 2>/dev/null || true
|
||||||
|
|
||||||
|
cat <<NEXT
|
||||||
|
|
||||||
|
=============================================================================
|
||||||
|
Composants du cluster installes.
|
||||||
|
=============================================================================
|
||||||
|
Etapes suivantes :
|
||||||
|
1. bash scripts/secrets-apply.sh # secrets SOPS -> cluster
|
||||||
|
2. bash scripts/deploy.sh <tag-image> # premier deploiement
|
||||||
|
=============================================================================
|
||||||
|
NEXT
|
||||||
76
infra/prod/scripts/deploy-monitoring.sh
Executable file
76
infra/prod/scripts/deploy-monitoring.sh
Executable file
@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Deploiement de la pile d'observabilite
|
||||||
|
# =============================================================================
|
||||||
|
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
# bash scripts/deploy-monitoring.sh
|
||||||
|
#
|
||||||
|
# Loki (journaux) + Promtail (collecte) + Prometheus (metriques) +
|
||||||
|
# Alertmanager (Discord) + Grafana (consultation).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
K8S_DIR="${HERE}/../k8s"
|
||||||
|
REPO_ROOT="$(cd "${HERE}/../../.." && pwd)"
|
||||||
|
|
||||||
|
log() { printf '\n>>> %s\n' "$*"; }
|
||||||
|
|
||||||
|
kubectl get ns monitoring >/dev/null 2>&1 || kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
|
||||||
|
|
||||||
|
kubectl -n monitoring get secret grafana-admin >/dev/null 2>&1 \
|
||||||
|
|| { echo "Secret grafana-admin absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; }
|
||||||
|
kubectl -n monitoring get secret alertmanager-secrets >/dev/null 2>&1 \
|
||||||
|
|| { echo "Secret alertmanager-secrets absent : lancez d'abord scripts/secrets-apply.sh" >&2; exit 1; }
|
||||||
|
|
||||||
|
# --- Tableaux de bord --------------------------------------------------------
|
||||||
|
# Reutilise les tableaux de bord deja ecrits pour la preprod plutot que d'en
|
||||||
|
# maintenir un second jeu.
|
||||||
|
DASHBOARD_SRC="${REPO_ROOT}/infra/logging/grafana/provisioning/dashboards"
|
||||||
|
if [[ -d "$DASHBOARD_SRC" ]]; then
|
||||||
|
log "Tableaux de bord depuis ${DASHBOARD_SRC}"
|
||||||
|
kubectl -n monitoring create configmap grafana-dashboards \
|
||||||
|
$(find "$DASHBOARD_SRC" -name '*.json' -exec printf -- '--from-file=%s ' {} +) \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
else
|
||||||
|
echo "AVERTISSEMENT: aucun tableau de bord trouve dans ${DASHBOARD_SRC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Loki"
|
||||||
|
kubectl apply -f "${K8S_DIR}/monitoring/01-loki.yaml"
|
||||||
|
log "Promtail"
|
||||||
|
kubectl apply -f "${K8S_DIR}/monitoring/02-promtail.yaml"
|
||||||
|
log "Prometheus"
|
||||||
|
kubectl apply -f "${K8S_DIR}/monitoring/03-prometheus.yaml"
|
||||||
|
log "node-exporter"
|
||||||
|
kubectl apply -f "${K8S_DIR}/monitoring/04-node-exporter.yaml"
|
||||||
|
log "Alertmanager"
|
||||||
|
kubectl apply -f "${K8S_DIR}/monitoring/05-alertmanager.yaml"
|
||||||
|
log "Grafana"
|
||||||
|
kubectl apply -f "${K8S_DIR}/monitoring/06-grafana.yaml"
|
||||||
|
|
||||||
|
log "Politiques reseau du namespace monitoring"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml"
|
||||||
|
|
||||||
|
log "Attente du demarrage"
|
||||||
|
kubectl -n monitoring rollout status deploy/loki --timeout=300s
|
||||||
|
kubectl -n monitoring rollout status deploy/prometheus --timeout=300s
|
||||||
|
kubectl -n monitoring rollout status deploy/alertmanager --timeout=180s
|
||||||
|
kubectl -n monitoring rollout status deploy/grafana --timeout=300s
|
||||||
|
|
||||||
|
log "Etat"
|
||||||
|
kubectl -n monitoring get pods,svc,pvc
|
||||||
|
|
||||||
|
cat <<'NEXT'
|
||||||
|
|
||||||
|
Grafana : https://grafana.xpeditis.com
|
||||||
|
Identifiants : Secret monitoring/grafana-admin
|
||||||
|
L'acces est filtre par IP (middleware monitoring-admin-ip-allowlist).
|
||||||
|
Adaptez la liste dans k8s/base/08-traefik-middlewares.yaml, sinon vous
|
||||||
|
obtiendrez un 403 depuis votre poste.
|
||||||
|
|
||||||
|
Verifier que les alertes partent bien, AVANT d'en avoir besoin :
|
||||||
|
kubectl -n monitoring port-forward svc/alertmanager 9093:9093
|
||||||
|
curl -XPOST http://localhost:9093/api/v2/alerts -H 'Content-Type: application/json' \
|
||||||
|
-d '[{"labels":{"alertname":"TestDeRoutage","severity":"avertissement"}}]'
|
||||||
|
NEXT
|
||||||
126
infra/prod/scripts/deploy.sh
Executable file
126
infra/prod/scripts/deploy.sh
Executable file
@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Deploiement d'une version en production
|
||||||
|
# =============================================================================
|
||||||
|
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
# bash scripts/deploy.sh prod-a1b2c3d
|
||||||
|
#
|
||||||
|
# Sequence :
|
||||||
|
# 1. verification que les images existent dans le registre
|
||||||
|
# 2. Job de migration (parallelisme 1) -- bloquant
|
||||||
|
# 3. mise a jour des images backend et frontend
|
||||||
|
# 4. attente du deploiement complet
|
||||||
|
# 5. tests de fumee sur les URLs publiques
|
||||||
|
# 6. retour arriere automatique si l'une des etapes echoue
|
||||||
|
#
|
||||||
|
# C'est la meme sequence que cd-main.yml : ce script est le chemin manuel de
|
||||||
|
# secours quand GitHub Actions est indisponible.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="${1:?usage: $0 <tag-image> ex: prod-a1b2c3d}"
|
||||||
|
NAMESPACE="${NAMESPACE:-xpeditis-prod}"
|
||||||
|
REGISTRY="${REGISTRY:-rg.fr-par.scw.cloud/weworkstudio}"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
K8S_DIR="${HERE}/../k8s"
|
||||||
|
|
||||||
|
log() { printf '\n>>> %s\n' "$*"; }
|
||||||
|
fail() { printf '\nECHEC: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
rollback() {
|
||||||
|
log "RETOUR ARRIERE"
|
||||||
|
kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-backend || true
|
||||||
|
kubectl -n "$NAMESPACE" rollout undo deploy/xpeditis-frontend || true
|
||||||
|
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=180s || true
|
||||||
|
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=180s || true
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
|
||||||
|
Les deploiements sont revenus a la version precedente.
|
||||||
|
|
||||||
|
ATTENTION : les MIGRATIONS DE BASE, elles, ne sont pas annulees. Si la version
|
||||||
|
retiree contenait une migration destructrice (colonne supprimee, type change),
|
||||||
|
l'ancienne version applicative peut ne plus fonctionner contre le schema
|
||||||
|
courant. Voir docs/mise-en-prod/15-exploitation-incidents.md, "Retour arriere
|
||||||
|
avec migration".
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- 0. Preconditions --------------------------------------------------------
|
||||||
|
kubectl get ns "$NAMESPACE" >/dev/null || fail "namespace $NAMESPACE introuvable"
|
||||||
|
kubectl -n "$NAMESPACE" get secret regcred >/dev/null \
|
||||||
|
|| fail "secret 'regcred' absent : les images ne pourront pas etre telechargees"
|
||||||
|
kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null \
|
||||||
|
|| fail "secrets applicatifs absents : lancez scripts/secrets-apply.sh"
|
||||||
|
|
||||||
|
BACKEND_IMAGE="${REGISTRY}/xpeditis-backend:${TAG}"
|
||||||
|
FRONTEND_IMAGE="${REGISTRY}/xpeditis-frontend:${TAG}"
|
||||||
|
EXPORTER_IMAGE="${REGISTRY}/xpeditis-log-exporter:${TAG}"
|
||||||
|
|
||||||
|
log "Version deployee : ${TAG}"
|
||||||
|
kubectl -n "$NAMESPACE" get deploy -o wide
|
||||||
|
|
||||||
|
# --- 1. Configuration --------------------------------------------------------
|
||||||
|
log "Application de la configuration (ConfigMap, Ingress, politiques)"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/00-namespaces.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/01-limits.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/02-configmap-backend.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/08-traefik-middlewares.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/10-network-policies.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/11-certificate.yaml"
|
||||||
|
kubectl apply -f "${K8S_DIR}/base/09-ingress.yaml"
|
||||||
|
|
||||||
|
# Empreinte de la configuration : sans elle, un changement de ConfigMap ne
|
||||||
|
# provoque aucun redemarrage et reste sans effet jusqu'au deploiement suivant.
|
||||||
|
CONFIG_SUM="$(kubectl -n "$NAMESPACE" get cm xpeditis-backend-config -o yaml \
|
||||||
|
| sha256sum | cut -c1-16)"
|
||||||
|
|
||||||
|
# --- 2. Migrations -----------------------------------------------------------
|
||||||
|
JOB_NAME="xpeditis-migrate-${TAG//[^a-z0-9-]/-}"
|
||||||
|
log "Migrations de base (Job ${JOB_NAME})"
|
||||||
|
|
||||||
|
kubectl -n "$NAMESPACE" delete job "$JOB_NAME" --ignore-not-found >/dev/null
|
||||||
|
|
||||||
|
sed -e "s|__IMAGE_TAG__|${TAG}|g" "${K8S_DIR}/base/07-migration-job.yaml" \
|
||||||
|
| kubectl apply -f -
|
||||||
|
|
||||||
|
if ! kubectl -n "$NAMESPACE" wait --for=condition=complete "job/${JOB_NAME}" --timeout=900s; then
|
||||||
|
echo "--- journaux du Job de migration ---" >&2
|
||||||
|
kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=200 >&2 || true
|
||||||
|
fail "les migrations ont echoue : AUCUNE image n'a ete deployee, la production tourne toujours sur la version precedente"
|
||||||
|
fi
|
||||||
|
kubectl -n "$NAMESPACE" logs "job/${JOB_NAME}" --tail=50
|
||||||
|
|
||||||
|
# --- 3. Deploiement ----------------------------------------------------------
|
||||||
|
log "Mise a jour du backend"
|
||||||
|
kubectl -n "$NAMESPACE" patch deploy xpeditis-backend --type=strategic -p \
|
||||||
|
"{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"xpeditis.com/config-checksum\":\"${CONFIG_SUM}\"}}}}}"
|
||||||
|
kubectl -n "$NAMESPACE" set image deploy/xpeditis-backend "backend=${BACKEND_IMAGE}"
|
||||||
|
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-backend --timeout=300s || rollback
|
||||||
|
|
||||||
|
log "Mise a jour du frontend"
|
||||||
|
kubectl -n "$NAMESPACE" set image deploy/xpeditis-frontend "frontend=${FRONTEND_IMAGE}"
|
||||||
|
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-frontend --timeout=300s || rollback
|
||||||
|
|
||||||
|
# Le collecteur de logs n'est pas critique : son echec ne doit pas declencher
|
||||||
|
# un retour arriere de l'application.
|
||||||
|
log "Mise a jour du log-exporter"
|
||||||
|
kubectl -n "$NAMESPACE" set image deploy/xpeditis-log-exporter "log-exporter=${EXPORTER_IMAGE}" || true
|
||||||
|
kubectl -n "$NAMESPACE" rollout status deploy/xpeditis-log-exporter --timeout=180s \
|
||||||
|
|| log "AVERTISSEMENT: le log-exporter n'a pas demarre (non bloquant)"
|
||||||
|
|
||||||
|
# --- 4. Tests de fumee -------------------------------------------------------
|
||||||
|
log "Tests de fumee"
|
||||||
|
if ! bash "${HERE}/smoke-test.sh"; then
|
||||||
|
rollback
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 5. Compte rendu ---------------------------------------------------------
|
||||||
|
log "Deploiement termine"
|
||||||
|
kubectl -n "$NAMESPACE" get pods -o wide
|
||||||
|
echo
|
||||||
|
echo "Backend : ${BACKEND_IMAGE}"
|
||||||
|
echo "Frontend : ${FRONTEND_IMAGE}"
|
||||||
|
echo
|
||||||
|
echo "Retour arriere manuel si necessaire :"
|
||||||
|
echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-backend"
|
||||||
|
echo " kubectl -n ${NAMESPACE} rollout undo deploy/xpeditis-frontend"
|
||||||
151
infra/prod/scripts/harden-seed-data.sh
Executable file
151
infra/prod/scripts/harden-seed-data.sh
Executable file
@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Neutralisation des comptes de demonstration -- OUTIL DE SECOURS ET D'AUDIT
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# LE CAS NOMINAL N'A PLUS BESOIN DE CE SCRIPT.
|
||||||
|
#
|
||||||
|
# Le traitement est desormais fait par les migrations, donc automatiquement et
|
||||||
|
# sans risque d'oubli :
|
||||||
|
#
|
||||||
|
# 1730000000007-SeedTestUsers ne s'execute plus si
|
||||||
|
# NODE_ENV=production
|
||||||
|
# 1756000000000-NeutralizeSeedAccountsInProduction filet de securite
|
||||||
|
# 1756000000001-BootstrapAdminFromEnv cree VOTRE administrateur
|
||||||
|
#
|
||||||
|
# Ce script reste utile dans trois situations :
|
||||||
|
#
|
||||||
|
# - une base de production migree AVANT l'ajout de la garde NODE_ENV ;
|
||||||
|
# - un deploiement ou NODE_ENV n'etait pas correctement positionne (le journal
|
||||||
|
# du Job de migration affiche alors "Seeded test users successfully") ;
|
||||||
|
# - une verification manuelle : il affiche l'etat des comptes sans rien
|
||||||
|
# changer s'il n'y a rien a changer.
|
||||||
|
#
|
||||||
|
# ssh deploy@<db-01>
|
||||||
|
# sudo bash /opt/xpeditis/infra-prod/scripts/harden-seed-data.sh
|
||||||
|
#
|
||||||
|
# RAPPEL DU PROBLEME
|
||||||
|
#
|
||||||
|
# La migration 1730000000007-SeedTestUsers cree trois comptes dont le mot de
|
||||||
|
# passe est ecrit en clair dans le depot :
|
||||||
|
#
|
||||||
|
# admin@xpeditis.com role ADMIN Password123!
|
||||||
|
# manager@xpeditis.com role MANAGER Password123!
|
||||||
|
# user@xpeditis.com role USER Password123!
|
||||||
|
#
|
||||||
|
# Sur une base de production, cela donne un acces complet a la plateforme a
|
||||||
|
# quiconque a lu le depot.
|
||||||
|
#
|
||||||
|
# Ce script ne SUPPRIME pas les lignes (des cles etrangeres peuvent y pointer,
|
||||||
|
# et une suppression en cascade dans audit_logs serait pire). Il :
|
||||||
|
# 1. renomme les adresses vers un domaine invalide -- ce qui libere au passage
|
||||||
|
# admin@xpeditis.com pour votre vrai compte ;
|
||||||
|
# 2. remplace le mot de passe par une valeur aleatoire inutilisable ;
|
||||||
|
# 3. desactive les comptes (is_active = false).
|
||||||
|
#
|
||||||
|
# Idempotent : peut etre relance sans risque.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
COMPOSE_DIR="${COMPOSE_DIR:-/opt/xpeditis/data-node}"
|
||||||
|
ENV_FILE="${ENV_FILE:-${COMPOSE_DIR}/.env.data}"
|
||||||
|
|
||||||
|
[[ -f "$ENV_FILE" ]] || { echo "Fichier d'environnement introuvable : $ENV_FILE" >&2; exit 1; }
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a; source "$ENV_FILE"; set +a
|
||||||
|
|
||||||
|
psql_run() {
|
||||||
|
docker compose -f "${COMPOSE_DIR}/docker-compose.data.yml" --env-file "$ENV_FILE" \
|
||||||
|
exec -T -u postgres postgres psql -v ON_ERROR_STOP=1 -d "$POSTGRES_DB" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ">>> Etat avant intervention"
|
||||||
|
psql_run -c "
|
||||||
|
SELECT email, role, is_active
|
||||||
|
FROM users
|
||||||
|
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
|
||||||
|
"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Neutralisation"
|
||||||
|
psql_run <<'SQL'
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Mot de passe remplace par une valeur aleatoire : le format reste un hash
|
||||||
|
-- Argon2 valide en apparence, mais aucun mot de passe ne peut y correspondre.
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
email = 'seed-desactive-' || substr(id::text, 1, 8) || '@invalid.local',
|
||||||
|
-- md5(random()) plutot que gen_random_bytes : pas besoin de l'extension
|
||||||
|
-- pgcrypto, qui n'est pas installee sur cette base.
|
||||||
|
password_hash = '$argon2id$v=19$m=65536,t=3,p=4$' || md5(random()::text)
|
||||||
|
|| '$' || md5(random()::text) || md5(clock_timestamp()::text),
|
||||||
|
is_active = false,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE email IN ('admin@xpeditis.com', 'manager@xpeditis.com', 'user@xpeditis.com');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Etat apres intervention"
|
||||||
|
psql_run -c "
|
||||||
|
SELECT email, role, is_active
|
||||||
|
FROM users
|
||||||
|
WHERE email LIKE 'seed-desactive-%@invalid.local';
|
||||||
|
"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Controle : aucun compte de demonstration ne doit subsister"
|
||||||
|
RESTE=$(psql_run -tAc "
|
||||||
|
SELECT count(*) FROM users
|
||||||
|
WHERE email IN ('admin@xpeditis.com','manager@xpeditis.com','user@xpeditis.com');
|
||||||
|
")
|
||||||
|
if [[ "$RESTE" != "0" ]]; then
|
||||||
|
echo "ECHEC : ${RESTE} compte(s) de demonstration encore actifs." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK : aucun compte de demonstration actif."
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Organisations de demonstration presentes (a examiner, non modifiees)"
|
||||||
|
# Non supprimees automatiquement : les comptes desactives y sont rattaches, et
|
||||||
|
# une suppression en cascade toucherait aussi audit_logs.
|
||||||
|
psql_run -c "
|
||||||
|
SELECT id, name, created_at
|
||||||
|
FROM organizations
|
||||||
|
WHERE name IN ('Test Freight Forwarder Inc.','Demo Shipping Company','Sample Shipper Ltd.');
|
||||||
|
"
|
||||||
|
|
||||||
|
cat <<'NEXT'
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
Etape suivante : disposer d'un administrateur.
|
||||||
|
|
||||||
|
La migration 1756000000001-BootstrapAdminFromEnv s'en charge normalement, a
|
||||||
|
partir de BOOTSTRAP_ADMIN_EMAIL (ConfigMap). Si elle a ete ignoree parce qu'un
|
||||||
|
ADMIN actif existait alors -- typiquement le compte de demonstration que ce
|
||||||
|
script vient de neutraliser -- relancez simplement le Job de migration :
|
||||||
|
|
||||||
|
kubectl -n xpeditis-prod delete job -l app.kubernetes.io/name=xpeditis-migrate
|
||||||
|
# puis redeployez, ou rejouez le Job pour le tag courant
|
||||||
|
|
||||||
|
Elle ne rejouera pas les migrations deja appliquees ; pour forcer uniquement
|
||||||
|
l'amorcage, creez votre compte a la main :
|
||||||
|
|
||||||
|
1. Inscrivez-vous sur https://app.xpeditis.com/fr/register
|
||||||
|
2. Promouvez le compte :
|
||||||
|
UPDATE users SET role = 'ADMIN' WHERE email = '<votre adresse>';
|
||||||
|
|
||||||
|
Dans les deux cas, verifiez qu'il n'existe qu'un seul ADMIN actif :
|
||||||
|
|
||||||
|
SELECT email, role, is_active FROM users WHERE role = 'ADMIN';
|
||||||
|
|
||||||
|
Puis controlez depuis l'exterieur que l'ancien compte est bien mort :
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
|
||||||
|
https://api.xpeditis.com/api/v1/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"admin@xpeditis.com","password":"Password123!"}'
|
||||||
|
# Attendu : 401 (et surtout pas 200 ni 201)
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
NEXT
|
||||||
187
infra/prod/scripts/preflight-check.sh
Executable file
187
infra/prod/scripts/preflight-check.sh
Executable file
@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Controle go / no-go avant ouverture au public
|
||||||
|
# =============================================================================
|
||||||
|
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
# bash scripts/preflight-check.sh
|
||||||
|
#
|
||||||
|
# Chaque point BLOQUANT en echec doit etre corrige avant d'ouvrir le service.
|
||||||
|
# Les points d'AVERTISSEMENT peuvent etre acceptes consciemment et notes dans
|
||||||
|
# le registre des risques.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
NAMESPACE=xpeditis-prod
|
||||||
|
API="${PROD_API_URL:-https://api.xpeditis.com}"
|
||||||
|
DB_HOST="${DB_PRIVATE_IP:-10.10.1.20}"
|
||||||
|
DB_PUBLIC_IP="${DB_PUBLIC_IP:-}"
|
||||||
|
|
||||||
|
BLOCK=0
|
||||||
|
WARN=0
|
||||||
|
|
||||||
|
ok() { printf ' \033[32m[OK]\033[0m %s\n' "$1"; }
|
||||||
|
block() { printf ' \033[31m[BLOQUANT]\033[0m %s\n' "$1"; BLOCK=$((BLOCK+1)); }
|
||||||
|
warn() { printf ' \033[33m[ATTENTION]\033[0m %s\n' "$1"; WARN=$((WARN+1)); }
|
||||||
|
|
||||||
|
section() { printf '\n\033[1m%s\033[0m\n' "$1"; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "1. Cluster"
|
||||||
|
|
||||||
|
if kubectl get nodes >/dev/null 2>&1; then
|
||||||
|
ok "Cluster joignable"
|
||||||
|
if kubectl get nodes --no-headers | grep -qv ' Ready'; then
|
||||||
|
block "Un noeud n'est pas Ready"
|
||||||
|
else
|
||||||
|
ok "Tous les noeuds sont Ready"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
block "Cluster injoignable : rien d'autre ne peut etre verifie"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kubectl -n "$NAMESPACE" get pods --no-headers 2>/dev/null | grep -qE 'CrashLoop|ImagePull|Error|Pending'; then
|
||||||
|
block "Des pods ne sont pas sains dans $NAMESPACE"
|
||||||
|
else
|
||||||
|
ok "Tous les pods de $NAMESPACE sont sains"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for d in xpeditis-backend xpeditis-frontend; do
|
||||||
|
READY=$(kubectl -n "$NAMESPACE" get deploy "$d" -o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo 0)
|
||||||
|
[[ "${READY:-0}" -ge 2 ]] && ok "$d : ${READY} replicas prets" \
|
||||||
|
|| warn "$d : ${READY:-0} replica(s) pret(s), 2 attendus (pas de haute disponibilite)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "2. Secrets"
|
||||||
|
|
||||||
|
if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets >/dev/null 2>&1; then
|
||||||
|
ok "Secrets applicatifs presents"
|
||||||
|
JWT=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.JWT_SECRET}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
|
||||||
|
[[ ${#JWT} -ge 32 ]] && ok "JWT_SECRET fait ${#JWT} caracteres" || block "JWT_SECRET trop court (${#JWT} caracteres, 32 minimum)"
|
||||||
|
|
||||||
|
# Les secrets publies dans infra/preprod/docker-stack.preprod.yml sont dans
|
||||||
|
# l'historique Git : les retrouver en production serait une compromission.
|
||||||
|
for compromis in "4C4tQC8qym" "9Lc3M9qoPBeHLKHDXGUf1" "hXiy5GMPswMtxMZujjS2O" "RBJfD0QVXC5JDfAHCwdUW"; do
|
||||||
|
if kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o json 2>/dev/null \
|
||||||
|
| grep -q "$(printf '%s' "$compromis" | base64 | cut -c1-12)"; then
|
||||||
|
block "Un secret de preprod (present dans Git, donc compromis) est reutilise en production"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
ok "Aucun secret de preprod detecte"
|
||||||
|
|
||||||
|
STRIPE=$(kubectl -n "$NAMESPACE" get secret xpeditis-backend-secrets -o jsonpath='{.data.STRIPE_SECRET_KEY}' 2>/dev/null | base64 -d 2>/dev/null || echo "")
|
||||||
|
case "$STRIPE" in
|
||||||
|
sk_live_*) ok "Cle Stripe en mode LIVE" ;;
|
||||||
|
sk_test_*) block "Cle Stripe en mode TEST : aucun paiement reel ne sera encaisse" ;;
|
||||||
|
*) warn "Cle Stripe absente ou non reconnue" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
block "Secrets applicatifs absents"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kubectl -n "$NAMESPACE" get secret regcred >/dev/null 2>&1; then
|
||||||
|
ok "Acces au registre configure"
|
||||||
|
else
|
||||||
|
block "Secret 'regcred' absent : aucune image ne pourra etre telechargee"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if sudo k3s secrets-encrypt status 2>/dev/null | grep -qi 'Encryption Status: Enabled'; then
|
||||||
|
ok "Chiffrement des Secrets au repos actif"
|
||||||
|
else
|
||||||
|
warn "Chiffrement des Secrets au repos non verifiable depuis ce poste (a controler sur app-01)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "3. TLS et exposition"
|
||||||
|
|
||||||
|
CERT=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || echo "")
|
||||||
|
[[ "$CERT" == "True" ]] && ok "Certificat wildcard emis" || block "Certificat wildcard non pret"
|
||||||
|
|
||||||
|
ISSUER=$(kubectl -n "$NAMESPACE" get certificate xpeditis-wildcard -o jsonpath='{.spec.issuerRef.name}' 2>/dev/null || echo "")
|
||||||
|
[[ "$ISSUER" == "letsencrypt-prod" ]] && ok "Emetteur : letsencrypt-prod" \
|
||||||
|
|| block "Emetteur '$ISSUER' : un certificat de staging n'est pas reconnu par les navigateurs"
|
||||||
|
|
||||||
|
if curl -sSI --max-time 15 "${API}/api/v1/health" 2>/dev/null | grep -qi '^strict-transport-security:'; then
|
||||||
|
ok "HSTS actif"
|
||||||
|
else
|
||||||
|
block "En-tete HSTS absent"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$DB_PUBLIC_IP" ]]; then
|
||||||
|
if command -v nc >/dev/null; then
|
||||||
|
if nc -z -w3 "$DB_PUBLIC_IP" 5432 2>/dev/null; then
|
||||||
|
block "PostgreSQL repond sur l'IP PUBLIQUE de db-01"
|
||||||
|
else
|
||||||
|
ok "PostgreSQL injoignable publiquement"
|
||||||
|
fi
|
||||||
|
if nc -z -w3 "$DB_PUBLIC_IP" 6379 2>/dev/null; then
|
||||||
|
block "Redis repond sur l'IP PUBLIQUE de db-01"
|
||||||
|
else
|
||||||
|
ok "Redis injoignable publiquement"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "DB_PUBLIC_IP non fournie : exposition de la base non verifiee"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "4. Comptes de demonstration"
|
||||||
|
|
||||||
|
# La migration 1730000000007-SeedTestUsers creait admin@xpeditis.com avec le mot
|
||||||
|
# de passe "Password123!", ecrit en clair dans le depot. Elle ne s'execute plus
|
||||||
|
# quand NODE_ENV=production, et 1756000000000 neutralise ces comptes s'ils
|
||||||
|
# existent malgre tout.
|
||||||
|
#
|
||||||
|
# Ce controle verifie le RESULTAT en conditions reelles, pas l'intention : c'est
|
||||||
|
# le seul moyen de detecter un NODE_ENV mal positionne ou une base restauree
|
||||||
|
# depuis une sauvegarde anterieure. Le controle le plus important de la liste.
|
||||||
|
for compte in admin manager user; do
|
||||||
|
CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
|
||||||
|
-X POST "${API}/api/v1/auth/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"${compte}@xpeditis.com\",\"password\":\"Password123!\"}" 2>/dev/null || echo "000")
|
||||||
|
case "$CODE" in
|
||||||
|
200|201) block "Le compte de demonstration ${compte}@xpeditis.com accepte encore Password123! (HTTP ${CODE})" ;;
|
||||||
|
000) warn "Impossible de tester ${compte}@xpeditis.com (API injoignable)" ;;
|
||||||
|
*) ok "${compte}@xpeditis.com neutralise (HTTP ${CODE})" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " Verifier aussi qu'il n'existe qu'un seul ADMIN actif, sur db-01 :"
|
||||||
|
echo " SELECT email, role, is_active FROM users WHERE role = 'ADMIN';"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "5. Sauvegardes"
|
||||||
|
|
||||||
|
cat <<'MANUEL'
|
||||||
|
Ces points se verifient sur db-01, ils ne peuvent pas l'etre d'ici :
|
||||||
|
|
||||||
|
ssh deploy@<db-01>
|
||||||
|
systemctl list-timers 'xpeditis-*' # timers actifs
|
||||||
|
sudo journalctl -u xpeditis-backup -n 30 # derniere execution
|
||||||
|
sudo /opt/xpeditis/data-node/backup/pg-restore.sh verify
|
||||||
|
|
||||||
|
Une sauvegarde jamais restauree n'est pas une sauvegarde. Le test de
|
||||||
|
restauration DOIT avoir ete passe au moins une fois avant l'ouverture.
|
||||||
|
MANUEL
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
section "6. Fonctionnement"
|
||||||
|
|
||||||
|
if bash "$(dirname "${BASH_SOURCE[0]}")/smoke-test.sh" >/dev/null 2>&1; then
|
||||||
|
ok "Tests de fumee au vert"
|
||||||
|
else
|
||||||
|
block "Tests de fumee en echec (relancer smoke-test.sh pour le detail)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
printf '\n=========================================\n'
|
||||||
|
printf ' Bloquants : %d Avertissements : %d\n' "$BLOCK" "$WARN"
|
||||||
|
printf '=========================================\n'
|
||||||
|
|
||||||
|
if [[ "$BLOCK" -gt 0 ]]; then
|
||||||
|
printf '\n\033[31mNO-GO\033[0m : corrigez les points bloquants avant d ouvrir au public.\n'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '\n\033[32mGO\033[0m : les controles bloquants sont passes.\n'
|
||||||
|
[[ "$WARN" -gt 0 ]] && printf 'Consignez les %d avertissement(s) dans le registre des risques.\n' "$WARN"
|
||||||
|
exit 0
|
||||||
88
infra/prod/scripts/refresh-cloudflare-ips.sh
Executable file
88
infra/prod/scripts/refresh-cloudflare-ips.sh
Executable file
@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Rafraichissement des rangs d'IP Cloudflare
|
||||||
|
# =============================================================================
|
||||||
|
# Cloudflare fait evoluer ses rangs. Deux endroits en dependent :
|
||||||
|
# - terraform/firewall.tf (qui peut atteindre 80/443 sur app-01)
|
||||||
|
# - la configuration Traefik (quelles IP ont le droit d'ecrire X-Forwarded-For)
|
||||||
|
#
|
||||||
|
# Une liste perimee produit deux pannes distinctes et peu evidentes :
|
||||||
|
# - du trafic legitime rejete par le firewall Hetzner ;
|
||||||
|
# - une IP client mal identifiee, donc une limitation de debit qui frappe
|
||||||
|
# tout le monde et des audit_logs faux.
|
||||||
|
#
|
||||||
|
# A relancer tous les trimestres, et systematiquement avant un `terraform apply`.
|
||||||
|
#
|
||||||
|
# bash scripts/refresh-cloudflare-ips.sh # compare et affiche
|
||||||
|
# bash scripts/refresh-cloudflare-ips.sh --write # met a jour firewall.tf
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
TF_FILE="${HERE}/../terraform/firewall.tf"
|
||||||
|
WRITE=false
|
||||||
|
[[ "${1:-}" == "--write" ]] && WRITE=true
|
||||||
|
|
||||||
|
echo ">>> Recuperation des rangs officiels Cloudflare"
|
||||||
|
V4="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v4)"
|
||||||
|
V6="$(curl -fsS --max-time 15 https://www.cloudflare.com/ips-v6)"
|
||||||
|
|
||||||
|
[[ -n "$V4" && -n "$V6" ]] || { echo "Reponse vide de Cloudflare, abandon." >&2; exit 1; }
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "IPv4 ($(echo "$V4" | wc -l | tr -d ' ') rangs) :"
|
||||||
|
echo "$V4" | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "IPv6 ($(echo "$V6" | wc -l | tr -d ' ') rangs) :"
|
||||||
|
echo "$V6" | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Rangs actuellement declares dans firewall.tf :"
|
||||||
|
grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u | sed 's/^/ /'
|
||||||
|
|
||||||
|
DIFF=$(diff <(printf '%s\n%s\n' "$V4" "$V6" | sort -u) \
|
||||||
|
<(grep -oE '"[0-9a-f:.]+/[0-9]+"' "$TF_FILE" | tr -d '"' | sort -u) || true)
|
||||||
|
|
||||||
|
if [[ -z "$DIFF" ]]; then
|
||||||
|
echo
|
||||||
|
echo ">>> Aucune difference : rien a faire."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> DIFFERENCES DETECTEES :"
|
||||||
|
echo "$DIFF"
|
||||||
|
|
||||||
|
if ! $WRITE; then
|
||||||
|
cat <<'MSG'
|
||||||
|
|
||||||
|
Relancez avec --write pour mettre a jour terraform/firewall.tf, puis :
|
||||||
|
cd terraform && terraform plan && terraform apply
|
||||||
|
|
||||||
|
Pensez aussi a reporter la liste dans la configuration Traefik :
|
||||||
|
/var/lib/rancher/k3s/server/manifests/traefik-config.yaml (sur app-01)
|
||||||
|
puis : sudo systemctl restart k3s
|
||||||
|
MSG
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
BLOCK_V4=$(echo "$V4" | sed 's/^/ "/; s/$/",/')
|
||||||
|
BLOCK_V6=$(echo "$V6" | sed 's/^/ "/; s/$/",/')
|
||||||
|
|
||||||
|
python3 - "$TF_FILE" <<PY
|
||||||
|
import re, sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
src = open(path).read()
|
||||||
|
src = re.sub(r'(cloudflare_ipv4 = \[\n).*?(\n \])',
|
||||||
|
lambda m: m.group(1) + """${BLOCK_V4}""".rstrip(',') + m.group(2),
|
||||||
|
src, flags=re.S)
|
||||||
|
src = re.sub(r'(cloudflare_ipv6 = \[\n).*?(\n \])',
|
||||||
|
lambda m: m.group(1) + """${BLOCK_V6}""".rstrip(',') + m.group(2),
|
||||||
|
src, flags=re.S)
|
||||||
|
open(path, 'w').write(src)
|
||||||
|
print("firewall.tf mis a jour")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Verifiez le diff avant d'appliquer :"
|
||||||
|
echo " git diff infra/prod/terraform/firewall.tf"
|
||||||
|
echo " cd infra/prod/terraform && terraform plan"
|
||||||
50
infra/prod/scripts/secrets-apply.sh
Executable file
50
infra/prod/scripts/secrets-apply.sh
Executable file
@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Application des secrets chiffres SOPS sur le cluster
|
||||||
|
# =============================================================================
|
||||||
|
# export KUBECONFIG=~/.kube/xpeditis-prod.yaml
|
||||||
|
# bash scripts/secrets-apply.sh
|
||||||
|
#
|
||||||
|
# Le contenu dechiffre ne touche JAMAIS le disque : sops ecrit sur la sortie
|
||||||
|
# standard, kubectl lit sur l'entree standard. Rien a nettoyer, rien a oublier.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SECRETS_FILE="${HERE}/../k8s/base/03-secrets.sops.yaml"
|
||||||
|
|
||||||
|
command -v sops >/dev/null || { echo "sops n'est pas installe." >&2; exit 1; }
|
||||||
|
[[ -f "$SECRETS_FILE" ]] || {
|
||||||
|
cat >&2 <<MSG
|
||||||
|
Fichier introuvable : $SECRETS_FILE
|
||||||
|
|
||||||
|
Creez-le a partir du gabarit :
|
||||||
|
cp k8s/base/03-secrets.template.yaml /tmp/secrets.yaml
|
||||||
|
\$EDITOR /tmp/secrets.yaml
|
||||||
|
sops -e /tmp/secrets.yaml > k8s/base/03-secrets.sops.yaml
|
||||||
|
shred -u /tmp/secrets.yaml
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refuse d'appliquer un fichier qui ne serait pas reellement chiffre : c'est le
|
||||||
|
# genre d'erreur qui ne se voit qu'une fois le secret pousse sur GitHub.
|
||||||
|
grep -q 'sops:' "$SECRETS_FILE" || {
|
||||||
|
echo "ERREUR: $SECRETS_FILE ne semble pas chiffre par SOPS. Abandon." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ">>> Application des secrets sur $(kubectl config current-context)"
|
||||||
|
sops -d "$SECRETS_FILE" | kubectl apply -f -
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo ">>> Secrets presents :"
|
||||||
|
kubectl -n xpeditis-prod get secrets
|
||||||
|
kubectl -n monitoring get secrets 2>/dev/null || true
|
||||||
|
kubectl -n cert-manager get secret cloudflare-api-token 2>/dev/null || true
|
||||||
|
|
||||||
|
cat <<'NEXT'
|
||||||
|
|
||||||
|
Rappel : modifier un Secret ne redemarre PAS les pods qui l'utilisent.
|
||||||
|
Pour que la nouvelle valeur soit prise en compte :
|
||||||
|
kubectl -n xpeditis-prod rollout restart deploy/xpeditis-backend
|
||||||
|
NEXT
|
||||||
81
infra/prod/scripts/smoke-test.sh
Executable file
81
infra/prod/scripts/smoke-test.sh
Executable file
@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Tests de fumee post-deploiement
|
||||||
|
# =============================================================================
|
||||||
|
# Verifie ce qu'un utilisateur constate reellement, depuis l'exterieur, a
|
||||||
|
# travers Cloudflare et Traefik -- pas l'etat interne du cluster.
|
||||||
|
#
|
||||||
|
# bash scripts/smoke-test.sh
|
||||||
|
#
|
||||||
|
# Code de retour 0 = production saine. Utilise par deploy.sh et cd-main.yml
|
||||||
|
# pour declencher un retour arriere automatique.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
API="${PROD_API_URL:-https://api.xpeditis.com}"
|
||||||
|
APP="${PROD_APP_URL:-https://app.xpeditis.com}"
|
||||||
|
SITE="${PROD_SITE_URL:-https://xpeditis.com}"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1"; shift
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
printf ' [OK] %s\n' "$label"; PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
printf ' [ECHEC] %s\n' "$label"; FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
http_code() { curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$1"; }
|
||||||
|
|
||||||
|
expect_code() {
|
||||||
|
local url="$1" expected="$2"
|
||||||
|
[[ "$(http_code "$url")" == "$expected" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_header() {
|
||||||
|
local url="$1" header="$2"
|
||||||
|
curl -sSI --max-time 15 "$url" | grep -qi "^${header}:"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Tests de fumee - $(date -Is)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "Disponibilite"
|
||||||
|
check "API en ligne (200 sur /api/v1/health)" expect_code "${API}/api/v1/health" 200
|
||||||
|
check "Frontend en ligne (app)" expect_code "${APP}/" 200
|
||||||
|
check "Vitrine en ligne (apex)" expect_code "${SITE}/" 200
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "TLS et redirections"
|
||||||
|
check "HTTP redirige vers HTTPS" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 'http://api.xpeditis.com/api/v1/health') =~ ^30 ]]"
|
||||||
|
check "Certificat valide (pas d'option -k)" curl -sS --max-time 15 -o /dev/null "${API}/api/v1/health"
|
||||||
|
check "En-tete HSTS present" expect_header "${API}/api/v1/health" "strict-transport-security"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Durcissement"
|
||||||
|
check "X-Frame-Options present" expect_header "${APP}/" "x-frame-options"
|
||||||
|
check "X-Content-Type-Options present" expect_header "${APP}/" "x-content-type-options"
|
||||||
|
# main.ts desactive Swagger en production sauf si SWAGGER_USERNAME/PASSWORD
|
||||||
|
# sont definis. 404 = desactive, 401 = protege : les deux sont acceptables,
|
||||||
|
# 200 signifie que la documentation de l'API est publique.
|
||||||
|
check "Swagger non accessible librement" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/docs') != '200' ]]"
|
||||||
|
check "Route protegee refuse l'anonyme (401/403)" bash -c "[[ \$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 '${API}/api/v1/bookings') =~ ^(401|403)$ ]]"
|
||||||
|
check "CORS refuse une origine inconnue" bash -c "! curl -sSI --max-time 15 -H 'Origin: https://evil.example' '${API}/api/v1/health' | grep -qi 'access-control-allow-origin: https://evil.example'"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Etat du cluster"
|
||||||
|
if command -v kubectl >/dev/null && kubectl get ns xpeditis-prod >/dev/null 2>&1; then
|
||||||
|
check "Aucun pod en erreur" bash -c "! kubectl -n xpeditis-prod get pods --no-headers | grep -qE 'CrashLoopBackOff|ImagePullBackOff|Error'"
|
||||||
|
check "Certificat cert-manager pret" bash -c "kubectl -n xpeditis-prod get certificate xpeditis-wildcard -o jsonpath='{.status.conditions[?(@.type==\"Ready\")].status}' | grep -q True"
|
||||||
|
else
|
||||||
|
echo " [saute] kubectl indisponible : verifications cluster ignorees"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
printf ' Reussis : %d Echecs : %d\n' "$PASS" "$FAIL"
|
||||||
|
echo "-----------------------------------------"
|
||||||
|
|
||||||
|
[[ "$FAIL" -eq 0 ]]
|
||||||
56
infra/prod/scripts/ssh-deploy-wrapper.sh
Executable file
56
infra/prod/scripts/ssh-deploy-wrapper.sh
Executable file
@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Enveloppe de la cle SSH de deploiement
|
||||||
|
# =============================================================================
|
||||||
|
# Reference depuis ~deploy/.ssh/authorized_keys sur app-01 :
|
||||||
|
#
|
||||||
|
# restrict,pty,command="/opt/xpeditis/infra-prod/scripts/ssh-deploy-wrapper.sh" ssh-ed25519 AAAA... github-actions-prod
|
||||||
|
#
|
||||||
|
# Le `command=` d'OpenSSH ignore ce que le client demande et execute ce script,
|
||||||
|
# en placant la commande d'origine dans SSH_ORIGINAL_COMMAND. Une cle volee ne
|
||||||
|
# donne donc pas un shell : elle ne peut lancer que ce qui est autorise ici.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CMD="${SSH_ORIGINAL_COMMAND:-}"
|
||||||
|
LOG_TAG=xpeditis-ssh-deploy
|
||||||
|
|
||||||
|
deny() {
|
||||||
|
logger -t "$LOG_TAG" -- "REFUS: ${CMD}"
|
||||||
|
echo "Commande non autorisee pour cette cle." >&2
|
||||||
|
exit 126
|
||||||
|
}
|
||||||
|
|
||||||
|
logger -t "$LOG_TAG" -- "DEMANDE: ${CMD}"
|
||||||
|
|
||||||
|
case "$CMD" in
|
||||||
|
# rsync doit pouvoir se lancer en mode serveur pour recevoir infra/prod.
|
||||||
|
# --server et --sender uniquement, chemin de destination contraint.
|
||||||
|
"rsync --server "*"/opt/xpeditis/infra-prod/"*)
|
||||||
|
exec $CMD
|
||||||
|
;;
|
||||||
|
|
||||||
|
# Deploiement d'une version. Le tag est valide par une expression stricte :
|
||||||
|
# sans cela, `deploy.sh "; rm -rf /"` serait accepte.
|
||||||
|
"deploy "*)
|
||||||
|
TAG="${CMD#deploy }"
|
||||||
|
[[ "$TAG" =~ ^prod-[a-f0-9]{7,40}$ ]] || deny
|
||||||
|
exec /opt/xpeditis/infra-prod/scripts/deploy.sh "$TAG"
|
||||||
|
;;
|
||||||
|
|
||||||
|
# Retour arriere d'urgence.
|
||||||
|
"rollback")
|
||||||
|
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-backend
|
||||||
|
kubectl -n xpeditis-prod rollout undo deploy/xpeditis-frontend
|
||||||
|
exec kubectl -n xpeditis-prod rollout status deploy/xpeditis-backend --timeout=180s
|
||||||
|
;;
|
||||||
|
|
||||||
|
# Diagnostic en lecture seule.
|
||||||
|
"status")
|
||||||
|
kubectl -n xpeditis-prod get pods,deploy -o wide
|
||||||
|
exec kubectl -n xpeditis-prod get events --sort-by=.lastTimestamp | tail -20
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
deny
|
||||||
|
;;
|
||||||
|
esac
|
||||||
76
infra/prod/terraform/cloud-init.yaml.tftpl
Normal file
76
infra/prod/terraform/cloud-init.yaml.tftpl
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
#cloud-config
|
||||||
|
# =============================================================================
|
||||||
|
# Amorcage minimal des serveurs Xpeditis prod
|
||||||
|
# =============================================================================
|
||||||
|
# Ce fichier ne fait que le strict necessaire pour obtenir un serveur joignable
|
||||||
|
# en SSH par un compte non-root. Tout le durcissement reel est fait par
|
||||||
|
# scripts/00-bootstrap-common.sh, versionne et relisible.
|
||||||
|
|
||||||
|
hostname: ${hostname}
|
||||||
|
fqdn: ${hostname}
|
||||||
|
preserve_hostname: false
|
||||||
|
|
||||||
|
users:
|
||||||
|
- name: ${deploy_user}
|
||||||
|
groups: [sudo]
|
||||||
|
shell: /bin/bash
|
||||||
|
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||||
|
lock_passwd: true
|
||||||
|
ssh_authorized_keys:
|
||||||
|
- ${ssh_public_key}
|
||||||
|
|
||||||
|
# Le compte root n'a ni mot de passe ni acces SSH par mot de passe.
|
||||||
|
disable_root: true
|
||||||
|
ssh_pwauth: false
|
||||||
|
|
||||||
|
package_update: true
|
||||||
|
package_upgrade: true
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- curl
|
||||||
|
- ca-certificates
|
||||||
|
- gnupg
|
||||||
|
- ufw
|
||||||
|
- fail2ban
|
||||||
|
- unattended-upgrades
|
||||||
|
- chrony
|
||||||
|
- jq
|
||||||
|
- git
|
||||||
|
- htop
|
||||||
|
- rsync
|
||||||
|
|
||||||
|
write_files:
|
||||||
|
# Pare-feu local minimal des le premier boot : le serveur n'est jamais
|
||||||
|
# accessible "nu", meme entre cloud-init et l'execution du script de
|
||||||
|
# durcissement.
|
||||||
|
- path: /etc/ssh/sshd_config.d/99-xpeditis-hardening.conf
|
||||||
|
permissions: "0644"
|
||||||
|
content: |
|
||||||
|
PermitRootLogin no
|
||||||
|
PasswordAuthentication no
|
||||||
|
KbdInteractiveAuthentication no
|
||||||
|
ChallengeResponseAuthentication no
|
||||||
|
PubkeyAuthentication yes
|
||||||
|
X11Forwarding no
|
||||||
|
AllowAgentForwarding no
|
||||||
|
AllowTcpForwarding yes
|
||||||
|
MaxAuthTries 3
|
||||||
|
MaxSessions 5
|
||||||
|
LoginGraceTime 20
|
||||||
|
ClientAliveInterval 300
|
||||||
|
ClientAliveCountMax 2
|
||||||
|
AllowUsers ${deploy_user}
|
||||||
|
|
||||||
|
runcmd:
|
||||||
|
- systemctl restart ssh || systemctl restart sshd
|
||||||
|
- systemctl enable --now fail2ban
|
||||||
|
- systemctl enable --now chrony
|
||||||
|
- |
|
||||||
|
ufw --force reset
|
||||||
|
ufw default deny incoming
|
||||||
|
ufw default allow outgoing
|
||||||
|
ufw allow 22/tcp
|
||||||
|
ufw --force enable
|
||||||
|
- touch /var/log/cloud-init-xpeditis-done
|
||||||
|
|
||||||
|
final_message: "Xpeditis prod node ${hostname} pret. Lancer scripts/00-bootstrap-common.sh."
|
||||||
157
infra/prod/terraform/firewall.tf
Normal file
157
infra/prod/terraform/firewall.tf
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Firewalls Hetzner Cloud (interfaces publiques uniquement)
|
||||||
|
# =============================================================================
|
||||||
|
# Politique : tout est ferme par defaut. On n'ouvre que le strict necessaire,
|
||||||
|
# et jamais vers 0.0.0.0/0 sauf pour le trafic web derriere Cloudflare.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Rangs d'IP Cloudflare. A rafraichir avec :
|
||||||
|
# curl -s https://www.cloudflare.com/ips-v4
|
||||||
|
# curl -s https://www.cloudflare.com/ips-v6
|
||||||
|
# Verifiez cette liste a chaque `terraform apply` (cf. scripts/refresh-cloudflare-ips.sh).
|
||||||
|
cloudflare_ipv4 = [
|
||||||
|
"173.245.48.0/20",
|
||||||
|
"103.21.244.0/22",
|
||||||
|
"103.22.200.0/22",
|
||||||
|
"103.31.4.0/22",
|
||||||
|
"141.101.64.0/18",
|
||||||
|
"108.162.192.0/18",
|
||||||
|
"190.93.240.0/20",
|
||||||
|
"188.114.96.0/20",
|
||||||
|
"197.234.240.0/22",
|
||||||
|
"198.41.128.0/17",
|
||||||
|
"162.158.0.0/15",
|
||||||
|
"104.16.0.0/13",
|
||||||
|
"104.24.0.0/14",
|
||||||
|
"172.64.0.0/13",
|
||||||
|
"131.0.72.0/22",
|
||||||
|
]
|
||||||
|
|
||||||
|
cloudflare_ipv6 = [
|
||||||
|
"2400:cb00::/32",
|
||||||
|
"2606:4700::/32",
|
||||||
|
"2803:f800::/32",
|
||||||
|
"2405:b500::/32",
|
||||||
|
"2405:8100::/32",
|
||||||
|
"2a06:98c0::/29",
|
||||||
|
"2c0f:f248::/32",
|
||||||
|
]
|
||||||
|
|
||||||
|
http_sources_v4 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv4 : ["0.0.0.0/0"]
|
||||||
|
http_sources_v6 = var.restrict_http_to_cloudflare ? local.cloudflare_ipv6 : ["::/0"]
|
||||||
|
http_sources = concat(local.http_sources_v4, local.http_sources_v6)
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Noeud applicatif (k3s server) ------------------------------------------
|
||||||
|
|
||||||
|
resource "hcloud_firewall" "app" {
|
||||||
|
name = "${var.project_name}-fw-app"
|
||||||
|
|
||||||
|
# SSH : uniquement depuis les IPs d'administration.
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "tcp"
|
||||||
|
port = "22"
|
||||||
|
source_ips = var.admin_ip_allowlist
|
||||||
|
description = "SSH administrateur"
|
||||||
|
}
|
||||||
|
|
||||||
|
# API Kubernetes : uniquement depuis les IPs d'administration.
|
||||||
|
# Les runners GitHub Actions n'ont pas d'IP fixe : le deploiement passe donc
|
||||||
|
# par SSH + kubectl local sur le serveur, pas par 6443 expose.
|
||||||
|
# Cf. .github/workflows/cd-main.yml.
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "tcp"
|
||||||
|
port = "6443"
|
||||||
|
source_ips = var.admin_ip_allowlist
|
||||||
|
description = "API k3s (kubectl administrateur)"
|
||||||
|
}
|
||||||
|
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "tcp"
|
||||||
|
port = "80"
|
||||||
|
source_ips = local.http_sources
|
||||||
|
description = "HTTP (redirection 301 + ACME HTTP-01 de secours)"
|
||||||
|
}
|
||||||
|
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "tcp"
|
||||||
|
port = "443"
|
||||||
|
source_ips = local.http_sources
|
||||||
|
description = "HTTPS via Cloudflare"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ICMP : utile pour le diagnostic reseau, sans risque notable.
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "icmp"
|
||||||
|
source_ips = ["0.0.0.0/0", "::/0"]
|
||||||
|
description = "ICMP (ping / MTU discovery)"
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Ouverture temporaire pour la CI/CD -------------------------------------
|
||||||
|
# Les runners GitHub Actions n'ont pas d'IP fixe : impossible de les inscrire
|
||||||
|
# une fois pour toutes dans une liste blanche, et ouvrir SSH au monde entier
|
||||||
|
# n'est pas une option.
|
||||||
|
#
|
||||||
|
# Ce firewall est attache a app-01 et reste VIDE en regime nominal. Le workflow
|
||||||
|
# cd-main.yml y injecte l'IP du runner juste avant le deploiement, puis le vide
|
||||||
|
# systematiquement (etape `if: always()`). La fenetre d'exposition dure le temps
|
||||||
|
# du deploiement, pour une seule IP, sur le seul port 22.
|
||||||
|
#
|
||||||
|
# `ignore_changes = [rule]` est indispensable : sans lui, le prochain
|
||||||
|
# `terraform apply` supprimerait une regle posee par la CI en cours d'execution.
|
||||||
|
|
||||||
|
resource "hcloud_firewall" "cicd" {
|
||||||
|
name = "${var.project_name}-fw-cicd"
|
||||||
|
|
||||||
|
# Aucune regle : l'etat au repos est "ferme".
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
managed = "terraform"
|
||||||
|
purpose = "cicd-temporaire"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [rule]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Noeud de donnees --------------------------------------------------------
|
||||||
|
# Aucun port applicatif expose publiquement. PostgreSQL et Redis n'ecoutent que
|
||||||
|
# sur l'IP privee (cf. conf/postgresql.conf et conf/redis.conf) et sont en plus
|
||||||
|
# filtres par nftables. Ce firewall ne laisse passer que le SSH d'administration.
|
||||||
|
|
||||||
|
resource "hcloud_firewall" "db" {
|
||||||
|
name = "${var.project_name}-fw-db"
|
||||||
|
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "tcp"
|
||||||
|
port = "22"
|
||||||
|
source_ips = var.admin_ip_allowlist
|
||||||
|
description = "SSH administrateur"
|
||||||
|
}
|
||||||
|
|
||||||
|
rule {
|
||||||
|
direction = "in"
|
||||||
|
protocol = "icmp"
|
||||||
|
source_ips = ["0.0.0.0/0", "::/0"]
|
||||||
|
description = "ICMP (ping / MTU discovery)"
|
||||||
|
}
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
infra/prod/terraform/network.tf
Normal file
52
infra/prod/terraform/network.tf
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Reseau prive Hetzner
|
||||||
|
# =============================================================================
|
||||||
|
# Le noeud de donnees n'est JAMAIS joignable depuis Internet sur 5432/6379.
|
||||||
|
# Tout le trafic applicatif -> base passe par ce reseau prive, isole au niveau
|
||||||
|
# du projet Hetzner.
|
||||||
|
#
|
||||||
|
# Attention : le trafic sur un reseau prive Hetzner n'est pas chiffre par
|
||||||
|
# l'hyperviseur. On ajoute donc deux couches par-dessus :
|
||||||
|
# 1. TLS PostgreSQL (ssl = on + DATABASE_SSL=true cote applicatif)
|
||||||
|
# 2. Filtrage nftables sur db-01 (cf. scripts/01-setup-data-node.sh)
|
||||||
|
# Les firewalls Hetzner Cloud ne filtrent que les interfaces PUBLIQUES.
|
||||||
|
|
||||||
|
resource "hcloud_network" "main" {
|
||||||
|
name = "${var.project_name}-net"
|
||||||
|
ip_range = var.network_cidr
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "hcloud_network_subnet" "main" {
|
||||||
|
network_id = hcloud_network.main.id
|
||||||
|
type = "cloud"
|
||||||
|
network_zone = "eu-central"
|
||||||
|
ip_range = var.subnet_cidr
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cle SSH partagee par les deux serveurs.
|
||||||
|
resource "hcloud_ssh_key" "admin" {
|
||||||
|
name = "${var.project_name}-admin"
|
||||||
|
public_key = var.ssh_public_key
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Repartit les deux VMs sur des hotes physiques differents : une panne materielle
|
||||||
|
# ne peut pas emporter l'app ET la base en meme temps.
|
||||||
|
resource "hcloud_placement_group" "spread" {
|
||||||
|
name = "${var.project_name}-spread"
|
||||||
|
type = "spread"
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
}
|
||||||
64
infra/prod/terraform/outputs.tf
Normal file
64
infra/prod/terraform/outputs.tf
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
output "app_public_ipv4" {
|
||||||
|
description = "IP publique du noeud applicatif. A pointer depuis Cloudflare (enregistrements A, proxifies)."
|
||||||
|
value = hcloud_server.app.ipv4_address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "app_public_ipv6" {
|
||||||
|
description = "IPv6 du noeud applicatif (enregistrements AAAA)."
|
||||||
|
value = hcloud_server.app.ipv6_address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "app_private_ip" {
|
||||||
|
description = "IP privee du noeud applicatif."
|
||||||
|
value = var.app_private_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
output "db_public_ipv4" {
|
||||||
|
description = "IP publique du noeud de donnees. NE JAMAIS publier en DNS : elle ne sert qu'au SSH d'administration."
|
||||||
|
value = hcloud_server.db.ipv4_address
|
||||||
|
}
|
||||||
|
|
||||||
|
output "db_private_ip" {
|
||||||
|
description = "IP privee du noeud de donnees. C'est cette valeur qui alimente DATABASE_HOST et REDIS_HOST."
|
||||||
|
value = var.db_private_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
output "pgdata_volume_device" {
|
||||||
|
description = "Chemin du peripherique du volume PostgreSQL sur db-01 (a monter sur /var/lib/xpeditis/pgdata)."
|
||||||
|
value = hcloud_volume.pgdata.linux_device
|
||||||
|
}
|
||||||
|
|
||||||
|
output "cicd_firewall_name" {
|
||||||
|
description = "Firewall temporaire pilote par cd-main.yml. Doit rester vide au repos."
|
||||||
|
value = hcloud_firewall.cicd.name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ssh_commands" {
|
||||||
|
description = "Commandes de connexion."
|
||||||
|
value = {
|
||||||
|
app = "ssh ${var.deploy_user}@${hcloud_server.app.ipv4_address}"
|
||||||
|
db = "ssh ${var.deploy_user}@${hcloud_server.db.ipv4_address}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "dns_records_to_create" {
|
||||||
|
description = "Enregistrements DNS a creer dans Cloudflare (tous proxifies, nuage orange)."
|
||||||
|
value = {
|
||||||
|
"xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||||
|
"www.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||||
|
"app.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||||
|
"api.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||||
|
"grafana.xpeditis.com" = "A ${hcloud_server.app.ipv4_address}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output "monthly_cost_estimate_eur" {
|
||||||
|
description = "Estimation indicative HT (tarifs Hetzner T2 2026, hors Storage Box et services tiers)."
|
||||||
|
value = {
|
||||||
|
app_server = "${var.app_server_type} : voir grille Hetzner"
|
||||||
|
db_server = "${var.db_server_type} : voir grille Hetzner"
|
||||||
|
volume = "${var.db_volume_size} Go x 0.048 EUR/Go = ${format("%.2f", var.db_volume_size * 0.048)} EUR"
|
||||||
|
backups = var.enable_hetzner_backups ? "+20% du prix des deux serveurs" : "desactives"
|
||||||
|
note = "Reference budgetaire : Xpeditis_Previsions_Couts.xlsx, feuille 'Hetzner (auto-heberge)'."
|
||||||
|
}
|
||||||
|
}
|
||||||
116
infra/prod/terraform/servers.tf
Normal file
116
infra/prod/terraform/servers.tf
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Serveurs
|
||||||
|
# =============================================================================
|
||||||
|
# Topologie retenue (2 VMs, cf. docs/mise-en-prod/00 et Excel previsions couts) :
|
||||||
|
#
|
||||||
|
# app-01 CPX41 8 vCPU / 16 Go k3s server + tous les pods (stateless)
|
||||||
|
# db-01 CPX31 4 vCPU / 8 Go PostgreSQL 15 + Redis 7 (stateful, Docker)
|
||||||
|
#
|
||||||
|
# Pourquoi la base HORS de Kubernetes : PostgreSQL en StatefulSet apporte de la
|
||||||
|
# complexite (PV, ordre de demarrage, upgrades) sans aucun gain a cette echelle.
|
||||||
|
# Le hors-cluster rend les sauvegardes, la PITR et les restaurations triviales,
|
||||||
|
# et permet de reconstruire integralement le noeud app sans toucher aux donnees.
|
||||||
|
|
||||||
|
resource "hcloud_server" "app" {
|
||||||
|
name = "${var.project_name}-app-01"
|
||||||
|
server_type = var.app_server_type
|
||||||
|
image = var.image
|
||||||
|
location = var.location
|
||||||
|
ssh_keys = [hcloud_ssh_key.admin.id]
|
||||||
|
# Deux firewalls : le permanent, et celui que la CI ouvre puis referme.
|
||||||
|
firewall_ids = [hcloud_firewall.app.id, hcloud_firewall.cicd.id]
|
||||||
|
placement_group_id = hcloud_placement_group.spread.id
|
||||||
|
backups = var.enable_hetzner_backups
|
||||||
|
|
||||||
|
public_net {
|
||||||
|
ipv4_enabled = true
|
||||||
|
ipv6_enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
network {
|
||||||
|
network_id = hcloud_network.main.id
|
||||||
|
ip = var.app_private_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
||||||
|
hostname = "${var.project_name}-app-01"
|
||||||
|
deploy_user = var.deploy_user
|
||||||
|
ssh_public_key = var.ssh_public_key
|
||||||
|
})
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
role = "app"
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [hcloud_network_subnet.main]
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
# Un changement d'image ou de user_data recreerait le serveur : on veut une
|
||||||
|
# decision explicite, pas une destruction silencieuse de la prod.
|
||||||
|
ignore_changes = [image, user_data]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "hcloud_server" "db" {
|
||||||
|
name = "${var.project_name}-db-01"
|
||||||
|
server_type = var.db_server_type
|
||||||
|
image = var.image
|
||||||
|
location = var.location
|
||||||
|
ssh_keys = [hcloud_ssh_key.admin.id]
|
||||||
|
firewall_ids = [hcloud_firewall.db.id]
|
||||||
|
placement_group_id = hcloud_placement_group.spread.id
|
||||||
|
backups = var.enable_hetzner_backups
|
||||||
|
|
||||||
|
public_net {
|
||||||
|
ipv4_enabled = true
|
||||||
|
ipv6_enabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
network {
|
||||||
|
network_id = hcloud_network.main.id
|
||||||
|
ip = var.db_private_ip
|
||||||
|
}
|
||||||
|
|
||||||
|
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
|
||||||
|
hostname = "${var.project_name}-db-01"
|
||||||
|
deploy_user = var.deploy_user
|
||||||
|
ssh_public_key = var.ssh_public_key
|
||||||
|
})
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
role = "data"
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
|
||||||
|
depends_on = [hcloud_network_subnet.main]
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
ignore_changes = [image, user_data]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Volume de donnees PostgreSQL -------------------------------------------
|
||||||
|
# Volume dedie plutot que le disque systeme : agrandissable a chaud, snapshotable
|
||||||
|
# independamment, et survit a une reinstallation complete du serveur.
|
||||||
|
|
||||||
|
resource "hcloud_volume" "pgdata" {
|
||||||
|
name = "${var.project_name}-pgdata"
|
||||||
|
size = var.db_volume_size
|
||||||
|
server_id = hcloud_server.db.id
|
||||||
|
automount = false
|
||||||
|
format = "ext4"
|
||||||
|
|
||||||
|
labels = {
|
||||||
|
project = var.project_name
|
||||||
|
role = "pgdata"
|
||||||
|
managed = "terraform"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle {
|
||||||
|
# Garde-fou : un `terraform destroy` ne doit jamais emporter les donnees.
|
||||||
|
prevent_destroy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
37
infra/prod/terraform/terraform.tfvars.example
Normal file
37
infra/prod/terraform/terraform.tfvars.example
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Copier en terraform.tfvars (gitignore) et completer.
|
||||||
|
# cp terraform.tfvars.example terraform.tfvars
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Token API Hetzner Cloud, projet "xpeditis-prod" UNIQUEMENT (Read & Write).
|
||||||
|
# Console Hetzner > Security > API tokens.
|
||||||
|
# Ne le mettez pas ici si vous preferez la variable d'environnement :
|
||||||
|
# export TF_VAR_hcloud_token="..."
|
||||||
|
hcloud_token = "REMPLACER"
|
||||||
|
|
||||||
|
project_name = "xpeditis-prod"
|
||||||
|
location = "fsn1"
|
||||||
|
|
||||||
|
# Cle publique SSH de l'administrateur (ed25519 recommande).
|
||||||
|
# ssh-keygen -t ed25519 -a 100 -C "xpeditis-prod-admin" -f ~/.ssh/xpeditis_prod
|
||||||
|
# cat ~/.ssh/xpeditis_prod.pub
|
||||||
|
ssh_public_key = "ssh-ed25519 AAAA... xpeditis-prod-admin"
|
||||||
|
|
||||||
|
# IP publique fixe depuis laquelle vous administrez (SSH + kubectl).
|
||||||
|
# Trouver la votre : curl -s https://ifconfig.me
|
||||||
|
# Si votre IP est dynamique, utilisez plutot un VPN a IP fixe, ou acceptez de
|
||||||
|
# mettre a jour cette liste puis de relancer `terraform apply`.
|
||||||
|
admin_ip_allowlist = [
|
||||||
|
"203.0.113.7/32",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Dimensionnement (phase 1 = 0-100 utilisateurs)
|
||||||
|
app_server_type = "cpx41" # 8 vCPU / 16 Go
|
||||||
|
db_server_type = "cpx31" # 4 vCPU / 8 Go
|
||||||
|
db_volume_size = 50 # Go
|
||||||
|
|
||||||
|
enable_hetzner_backups = true
|
||||||
|
|
||||||
|
# Laisser a true en regime nominal : personne ne doit pouvoir contourner
|
||||||
|
# Cloudflare en tapant directement l'IP d'origine.
|
||||||
|
restrict_http_to_cloudflare = true
|
||||||
120
infra/prod/terraform/variables.tf
Normal file
120
infra/prod/terraform/variables.tf
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
variable "hcloud_token" {
|
||||||
|
description = "Token API Hetzner Cloud (Read & Write), projet xpeditis-prod uniquement."
|
||||||
|
type = string
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Prefixe applique a toutes les ressources."
|
||||||
|
type = string
|
||||||
|
default = "xpeditis-prod"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "location" {
|
||||||
|
description = "Datacenter Hetzner. fsn1 = Falkenstein (DE), nbg1 = Nuremberg (DE), hel1 = Helsinki (FI). Rester dans l'UE pour le RGPD."
|
||||||
|
type = string
|
||||||
|
default = "fsn1"
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = contains(["fsn1", "nbg1", "hel1"], var.location)
|
||||||
|
error_message = "Le RGPD impose de rester en UE : fsn1, nbg1 ou hel1."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Dimensionnement (cf. Xpeditis_Previsions_Couts.xlsx, feuille Hetzner) ---
|
||||||
|
|
||||||
|
variable "app_server_type" {
|
||||||
|
description = "Type du noeud applicatif (k3s server + pods). CPX41 = 8 vCPU / 16 Go / 240 Go."
|
||||||
|
type = string
|
||||||
|
default = "cpx41"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_server_type" {
|
||||||
|
description = "Type du noeud de donnees (PostgreSQL + Redis). CPX31 = 4 vCPU / 8 Go / 160 Go."
|
||||||
|
type = string
|
||||||
|
default = "cpx31"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "image" {
|
||||||
|
description = "Image systeme de base."
|
||||||
|
type = string
|
||||||
|
default = "ubuntu-24.04"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_volume_size" {
|
||||||
|
description = "Volume dedie aux donnees PostgreSQL, en Go. Detache du disque systeme : on peut agrandir, snapshotter et reattacher sans toucher au serveur."
|
||||||
|
type = number
|
||||||
|
default = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "enable_hetzner_backups" {
|
||||||
|
description = "Snapshots automatiques Hetzner (+20% du prix du serveur). Ce n'est PAS une strategie de sauvegarde suffisante : cf. 12-sauvegardes-restauration.md."
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Reseau ------------------------------------------------------------------
|
||||||
|
|
||||||
|
variable "network_cidr" {
|
||||||
|
description = "CIDR du reseau prive Hetzner."
|
||||||
|
type = string
|
||||||
|
default = "10.10.0.0/16"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "subnet_cidr" {
|
||||||
|
description = "CIDR du sous-reseau."
|
||||||
|
type = string
|
||||||
|
default = "10.10.1.0/24"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "app_private_ip" {
|
||||||
|
description = "IP privee fixe du noeud applicatif."
|
||||||
|
type = string
|
||||||
|
default = "10.10.1.10"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "db_private_ip" {
|
||||||
|
description = "IP privee fixe du noeud de donnees."
|
||||||
|
type = string
|
||||||
|
default = "10.10.1.20"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Acces administrateur ----------------------------------------------------
|
||||||
|
|
||||||
|
variable "ssh_public_key" {
|
||||||
|
description = "Cle publique SSH (ed25519) de l'administrateur. C'est la seule methode d'authentification autorisee sur les serveurs."
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "admin_ip_allowlist" {
|
||||||
|
description = <<-EOT
|
||||||
|
IPs/CIDR autorises a atteindre SSH (22) et l'API Kubernetes (6443).
|
||||||
|
Mettez votre IP fixe ou celle de votre VPN, JAMAIS 0.0.0.0/0.
|
||||||
|
Format CIDR obligatoire, ex. ["203.0.113.7/32"].
|
||||||
|
EOT
|
||||||
|
type = list(string)
|
||||||
|
|
||||||
|
validation {
|
||||||
|
condition = !contains(var.admin_ip_allowlist, "0.0.0.0/0")
|
||||||
|
error_message = "Ouvrir SSH et l'API k3s au monde entier est interdit. Renseignez votre IP en /32."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "deploy_user" {
|
||||||
|
description = "Compte non-root utilise pour l'administration et les deploiements."
|
||||||
|
type = string
|
||||||
|
default = "deploy"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Cloudflare --------------------------------------------------------------
|
||||||
|
|
||||||
|
variable "restrict_http_to_cloudflare" {
|
||||||
|
description = <<-EOT
|
||||||
|
Si true, seuls les rangs d'IP Cloudflare peuvent joindre 80/443 sur le noeud
|
||||||
|
applicatif : impossible de contourner le WAF en tapant l'IP d'origine.
|
||||||
|
Mettre a false UNIQUEMENT le temps d'emettre le premier certificat en HTTP-01
|
||||||
|
ou si le proxy Cloudflare (nuage orange) est desactive.
|
||||||
|
EOT
|
||||||
|
type = bool
|
||||||
|
default = true
|
||||||
|
}
|
||||||
33
infra/prod/terraform/versions.tf
Normal file
33
infra/prod/terraform/versions.tf
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
terraform {
|
||||||
|
required_version = ">= 1.6.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
hcloud = {
|
||||||
|
source = "hetznercloud/hcloud"
|
||||||
|
version = "~> 1.48"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend distant recommande des que vous n'etes plus seul sur le projet.
|
||||||
|
# Par defaut le state reste local : il contient des donnees sensibles
|
||||||
|
# (IPs, ids), il est donc gitignore. Sauvegardez-le dans votre coffre-fort.
|
||||||
|
#
|
||||||
|
# Pour passer sur un backend S3 (Hetzner Object Storage compatible S3) :
|
||||||
|
#
|
||||||
|
# backend "s3" {
|
||||||
|
# bucket = "xpeditis-prod-tfstate"
|
||||||
|
# key = "prod/terraform.tfstate"
|
||||||
|
# region = "fsn1"
|
||||||
|
# endpoints = { s3 = "https://fsn1.your-objectstorage.com" }
|
||||||
|
# skip_credentials_validation = true
|
||||||
|
# skip_region_validation = true
|
||||||
|
# skip_requesting_account_id = true
|
||||||
|
# skip_s3_checksum = true
|
||||||
|
# use_path_style = true
|
||||||
|
# encrypt = true
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "hcloud" {
|
||||||
|
token = var.hcloud_token
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user