From ecc180ca0de8cf45c050e49c70f651aa32e6b987 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 10 Apr 2026 12:49:32 +0200 Subject: [PATCH] chore: ajouter scripts de backup et restore pour migrations Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/migrate-backup.sh | 206 +++++++++++++++++++++++++++++++++++ bin/migrate-restore.sh | 242 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100755 bin/migrate-backup.sh create mode 100755 bin/migrate-restore.sh diff --git a/bin/migrate-backup.sh b/bin/migrate-backup.sh new file mode 100755 index 0000000..328c495 --- /dev/null +++ b/bin/migrate-backup.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# ============================================================================ +# migrate-backup.sh +# ---------------------------------------------------------------------------- +# Crée une archive complète de l'application (BDD + fichiers + config) afin +# de migrer le CRM Ludikevent d'un serveur vers un autre. +# +# Contenu de l'archive : +# - dump PostgreSQL (pg_dump --format=custom) +# - dump Redis (RDB) si redis-cli est disponible +# - dossiers d'uploads : public/media, public/images, public/pdf, public/seo +# - var/storage, sauvegarde +# - fichiers de config : .env.local, google.json +# - manifeste (versions, date, hostname) +# +# Usage : +# ./bin/migrate-backup.sh # archive dans ./sauvegarde/ +# ./bin/migrate-backup.sh -o /tmp/backup.tar.gz # chemin de sortie explicite +# ./bin/migrate-backup.sh --no-redis # ignorer Redis +# ./bin/migrate-backup.sh --docker # forcer l'utilisation de docker +# ============================================================================ +set -euo pipefail + +# --- Couleurs ---------------------------------------------------------------- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; RESET='\033[0m' +log() { printf "${CYAN}[backup]${RESET} %s\n" "$*"; } +ok() { printf "${GREEN}[ ok ]${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}[warn ]${RESET} %s\n" "$*"; } +err() { printf "${RED}[error]${RESET} %s\n" "$*" >&2; } + +# --- Args -------------------------------------------------------------------- +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT="" +SKIP_REDIS=0 +FORCE_DOCKER=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -o|--output) OUTPUT="$2"; shift 2 ;; + --no-redis) SKIP_REDIS=1; shift ;; + --docker) FORCE_DOCKER=1; shift ;; + -h|--help) + sed -n '2,22p' "$0"; exit 0 ;; + *) err "Argument inconnu : $1"; exit 1 ;; + esac +done + +cd "$PROJECT_DIR" + +TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)" +HOSTNAME_SHORT="$(hostname -s 2>/dev/null || hostname)" +[[ -z "$OUTPUT" ]] && OUTPUT="$PROJECT_DIR/sauvegarde/migrate_${HOSTNAME_SHORT}_${TIMESTAMP}.tar.gz" +mkdir -p "$(dirname "$OUTPUT")" + +WORK_DIR="$(mktemp -d -t crm-backup-XXXXXX)" +trap 'rm -rf "$WORK_DIR"' EXIT + +log "Projet : $PROJECT_DIR" +log "Destination : $OUTPUT" +log "Workdir : $WORK_DIR" + +# --- Lecture des variables d'environnement ---------------------------------- +ENV_FILE="" +for f in .env.local .env; do + [[ -f "$PROJECT_DIR/$f" ]] && ENV_FILE="$PROJECT_DIR/$f" && break +done +[[ -z "$ENV_FILE" ]] && { err "Aucun fichier .env trouvé."; exit 1; } +log "Env source : $ENV_FILE" + +# Récupère DATABASE_URL (dernière valeur définie, sans guillemets) +DATABASE_URL="$(grep -E '^DATABASE_URL=' "$ENV_FILE" | tail -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")" +[[ -z "$DATABASE_URL" ]] && { err "DATABASE_URL introuvable dans $ENV_FILE"; exit 1; } + +# postgresql://user:pass@host:port/db?... +re='^postgres(ql)?://([^:]+):([^@]*)@([^:/]+)(:([0-9]+))?/([^?]+)' +if [[ ! "$DATABASE_URL" =~ $re ]]; then + err "DATABASE_URL non reconnue : $DATABASE_URL"; exit 1 +fi +PG_USER="${BASH_REMATCH[2]}" +PG_PASS="${BASH_REMATCH[3]}" +PG_HOST="${BASH_REMATCH[4]}" +PG_PORT="${BASH_REMATCH[6]:-5432}" +PG_DB="${BASH_REMATCH[7]}" + +log "PostgreSQL : $PG_USER@$PG_HOST:$PG_PORT/$PG_DB" + +# --- Détection du mode (natif ou docker) ------------------------------------ +USE_DOCKER=0 +if [[ $FORCE_DOCKER -eq 1 ]]; then + USE_DOCKER=1 +elif ! command -v pg_dump >/dev/null 2>&1; then + if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' | grep -q '^crm_db$'; then + USE_DOCKER=1 + warn "pg_dump non installé, fallback sur docker exec crm_db" + fi +fi + +# --- Dump PostgreSQL --------------------------------------------------------- +log "Dump PostgreSQL en cours…" +DUMP_FILE="$WORK_DIR/database.dump" +if [[ $USE_DOCKER -eq 1 ]]; then + docker exec -e PGPASSWORD="$PG_PASS" crm_db \ + pg_dump -U "$PG_USER" -d "$PG_DB" --format=custom --no-owner --no-acl > "$DUMP_FILE" +else + PGPASSWORD="$PG_PASS" pg_dump \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + --format=custom --no-owner --no-acl -f "$DUMP_FILE" +fi +DUMP_SIZE="$(du -h "$DUMP_FILE" | cut -f1)" +ok "Dump BDD : $DUMP_SIZE" + +# --- Dump Redis (best effort) ------------------------------------------------ +if [[ $SKIP_REDIS -eq 0 ]]; then + REDIS_DSN="$(grep -E '^REDIS_DSN=' "$ENV_FILE" | tail -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")" + if [[ -n "$REDIS_DSN" ]]; then + # redis://[password@]host[:port] + rre='^redis://(([^@]*)@)?([^:/]+)(:([0-9]+))?' + if [[ "$REDIS_DSN" =~ $rre ]]; then + R_PASS="${BASH_REMATCH[2]}" + R_HOST="${BASH_REMATCH[3]}" + R_PORT="${BASH_REMATCH[5]:-6379}" + REDIS_FILE="$WORK_DIR/redis.rdb" + log "Dump Redis ($R_HOST:$R_PORT)…" + if command -v redis-cli >/dev/null 2>&1; then + if [[ -n "$R_PASS" ]]; then + redis-cli -h "$R_HOST" -p "$R_PORT" -a "$R_PASS" --no-auth-warning --rdb "$REDIS_FILE" >/dev/null \ + && ok "Dump Redis : $(du -h "$REDIS_FILE" | cut -f1)" \ + || warn "Échec dump Redis (ignoré)" + else + redis-cli -h "$R_HOST" -p "$R_PORT" --rdb "$REDIS_FILE" >/dev/null \ + && ok "Dump Redis : $(du -h "$REDIS_FILE" | cut -f1)" \ + || warn "Échec dump Redis (ignoré)" + fi + else + warn "redis-cli absent, dump Redis ignoré" + fi + fi + fi +fi + +# --- Copie des fichiers (uploads + config) ----------------------------------- +FILES_DIR="$WORK_DIR/files" +mkdir -p "$FILES_DIR" + +PATHS=( + "public/media" + "public/images" + "public/pdf" + "public/seo" + "public/tmp-sign" + "var/storage" + "sauvegarde" + ".env.local" + "google.json" +) + +log "Archivage des fichiers…" +for p in "${PATHS[@]}"; do + if [[ -e "$PROJECT_DIR/$p" ]]; then + mkdir -p "$FILES_DIR/$(dirname "$p")" + cp -a "$PROJECT_DIR/$p" "$FILES_DIR/$p" + ok " + $p" + else + warn " - $p (absent)" + fi +done + +# Évite de réembarquer l'archive courante si elle est dans sauvegarde/ +if [[ "$OUTPUT" == "$PROJECT_DIR/sauvegarde/"* && -e "$FILES_DIR/sauvegarde/$(basename "$OUTPUT")" ]]; then + rm -f "$FILES_DIR/sauvegarde/$(basename "$OUTPUT")" +fi + +# --- Manifeste --------------------------------------------------------------- +GIT_COMMIT="$(git -C "$PROJECT_DIR" rev-parse HEAD 2>/dev/null || echo "n/a")" +GIT_BRANCH="$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "n/a")" +PHP_VER="$(php -r 'echo PHP_VERSION;' 2>/dev/null || echo "n/a")" + +cat > "$WORK_DIR/MANIFEST.txt" < +# ./bin/migrate-restore.sh --yes # sans confirmation +# ./bin/migrate-restore.sh --docker # forcer docker +# ./bin/migrate-restore.sh --no-redis # ignorer Redis +# ./bin/migrate-restore.sh --no-files # restaurer la BDD seulement +# ./bin/migrate-restore.sh --no-db # restaurer les fichiers seulement +# +# ⚠️ ATTENTION : la restauration ÉCRASE la base de données et les fichiers +# de destination. Une sauvegarde locale est créée dans /tmp avant action. +# ============================================================================ +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; CYAN='\033[0;36m'; RESET='\033[0m' +log() { printf "${CYAN}[restore]${RESET} %s\n" "$*"; } +ok() { printf "${GREEN}[ ok ]${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}[ warn ]${RESET} %s\n" "$*"; } +err() { printf "${RED}[ error ]${RESET} %s\n" "$*" >&2; } + +# --- Args -------------------------------------------------------------------- +ARCHIVE="" +ASSUME_YES=0 +FORCE_DOCKER=0 +SKIP_REDIS=0 +SKIP_FILES=0 +SKIP_DB=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -y|--yes) ASSUME_YES=1; shift ;; + --docker) FORCE_DOCKER=1; shift ;; + --no-redis) SKIP_REDIS=1; shift ;; + --no-files) SKIP_FILES=1; shift ;; + --no-db) SKIP_DB=1; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + -*) err "Option inconnue : $1"; exit 1 ;; + *) ARCHIVE="$1"; shift ;; + esac +done + +[[ -z "$ARCHIVE" ]] && { err "Usage : $0 [--yes] [--docker] [--no-redis|--no-files|--no-db]"; exit 1; } +[[ ! -f "$ARCHIVE" ]] && { err "Archive introuvable : $ARCHIVE"; exit 1; } + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_DIR" + +WORK_DIR="$(mktemp -d -t crm-restore-XXXXXX)" +trap 'rm -rf "$WORK_DIR"' EXIT + +log "Projet : $PROJECT_DIR" +log "Archive : $ARCHIVE" +log "Workdir : $WORK_DIR" + +# --- Extraction -------------------------------------------------------------- +log "Extraction de l'archive…" +tar -xzf "$ARCHIVE" -C "$WORK_DIR" + +if [[ -f "$WORK_DIR/MANIFEST.txt" ]]; then + echo "----- MANIFEST -----" + cat "$WORK_DIR/MANIFEST.txt" + echo "--------------------" +fi + +# --- Lecture de la config destination --------------------------------------- +ENV_FILE="" +for f in .env.local .env; do + [[ -f "$PROJECT_DIR/$f" ]] && ENV_FILE="$PROJECT_DIR/$f" && break +done +[[ -z "$ENV_FILE" ]] && { err "Aucun .env trouvé sur le serveur cible."; exit 1; } +log "Env cible : $ENV_FILE" + +DATABASE_URL="$(grep -E '^DATABASE_URL=' "$ENV_FILE" | tail -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")" +re='^postgres(ql)?://([^:]+):([^@]*)@([^:/]+)(:([0-9]+))?/([^?]+)' +if [[ ! "$DATABASE_URL" =~ $re ]]; then + err "DATABASE_URL non reconnue : $DATABASE_URL"; exit 1 +fi +PG_USER="${BASH_REMATCH[2]}" +PG_PASS="${BASH_REMATCH[3]}" +PG_HOST="${BASH_REMATCH[4]}" +PG_PORT="${BASH_REMATCH[6]:-5432}" +PG_DB="${BASH_REMATCH[7]}" + +log "Cible BDD : $PG_USER@$PG_HOST:$PG_PORT/$PG_DB" + +# --- Confirmation ------------------------------------------------------------ +if [[ $ASSUME_YES -eq 0 ]]; then + echo + warn "Cette opération VA ÉCRASER :" + [[ $SKIP_DB -eq 0 ]] && echo " - la base $PG_DB sur $PG_HOST:$PG_PORT" + [[ $SKIP_FILES -eq 0 ]] && echo " - les dossiers public/media, public/pdf, public/images, public/seo, var/storage, sauvegarde, .env.local" + echo + read -r -p "Confirmer ? (tapez 'oui') : " ANSWER + [[ "$ANSWER" == "oui" ]] || { err "Annulé."; exit 1; } +fi + +# --- Détection du mode ------------------------------------------------------- +USE_DOCKER=0 +if [[ $FORCE_DOCKER -eq 1 ]]; then + USE_DOCKER=1 +elif ! command -v psql >/dev/null 2>&1 || ! command -v pg_restore >/dev/null 2>&1; then + if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' | grep -q '^crm_db$'; then + USE_DOCKER=1 + warn "psql/pg_restore absents, fallback docker exec crm_db" + fi +fi + +# --- Helpers psql / pg_restore ---------------------------------------------- +run_psql() { + if [[ $USE_DOCKER -eq 1 ]]; then + docker exec -i -e PGPASSWORD="$PG_PASS" crm_db psql -U "$PG_USER" -d "$1" -v ON_ERROR_STOP=1 + else + PGPASSWORD="$PG_PASS" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$1" -v ON_ERROR_STOP=1 + fi +} + +run_pg_restore() { + local dump_file="$1" + if [[ $USE_DOCKER -eq 1 ]]; then + docker exec -i -e PGPASSWORD="$PG_PASS" crm_db \ + pg_restore -U "$PG_USER" -d "$PG_DB" --no-owner --no-acl --clean --if-exists < "$dump_file" + else + PGPASSWORD="$PG_PASS" pg_restore \ + -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + --no-owner --no-acl --clean --if-exists "$dump_file" + fi +} + +# --- Sauvegarde de sécurité avant écrasement -------------------------------- +SAFETY_DIR="/tmp/crm-restore-safety-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$SAFETY_DIR" +log "Sauvegarde de sécurité dans : $SAFETY_DIR" + +if [[ $SKIP_DB -eq 0 ]]; then + log "Snapshot BDD courante…" + if [[ $USE_DOCKER -eq 1 ]]; then + docker exec -e PGPASSWORD="$PG_PASS" crm_db \ + pg_dump -U "$PG_USER" -d "$PG_DB" --format=custom --no-owner --no-acl > "$SAFETY_DIR/database.dump" 2>/dev/null \ + || warn "Snapshot BDD impossible (base inexistante ?)" + else + PGPASSWORD="$PG_PASS" pg_dump -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" \ + --format=custom --no-owner --no-acl -f "$SAFETY_DIR/database.dump" 2>/dev/null \ + || warn "Snapshot BDD impossible (base inexistante ?)" + fi +fi + +# --- Restauration BDD -------------------------------------------------------- +if [[ $SKIP_DB -eq 0 ]]; then + DUMP_FILE="$WORK_DIR/database.dump" + [[ ! -f "$DUMP_FILE" ]] && { err "database.dump absent de l'archive."; exit 1; } + + log "Création/réinitialisation de la base $PG_DB…" + # Création si inexistante (depuis postgres) + if [[ $USE_DOCKER -eq 1 ]]; then + docker exec -e PGPASSWORD="$PG_PASS" crm_db psql -U "$PG_USER" -d postgres -tc \ + "SELECT 1 FROM pg_database WHERE datname='$PG_DB'" | grep -q 1 \ + || docker exec -e PGPASSWORD="$PG_PASS" crm_db createdb -U "$PG_USER" "$PG_DB" + else + PGPASSWORD="$PG_PASS" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d postgres -tc \ + "SELECT 1 FROM pg_database WHERE datname='$PG_DB'" | grep -q 1 \ + || PGPASSWORD="$PG_PASS" createdb -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" "$PG_DB" + fi + + log "pg_restore en cours…" + run_pg_restore "$DUMP_FILE" || warn "pg_restore a renvoyé des warnings (souvent bénins)." + ok "BDD restaurée" +fi + +# --- Restauration des fichiers ---------------------------------------------- +if [[ $SKIP_FILES -eq 0 && -d "$WORK_DIR/files" ]]; then + log "Sauvegarde des dossiers existants…" + for p in public/media public/images public/pdf public/seo public/tmp-sign var/storage sauvegarde; do + if [[ -e "$PROJECT_DIR/$p" ]]; then + mkdir -p "$SAFETY_DIR/$(dirname "$p")" + cp -a "$PROJECT_DIR/$p" "$SAFETY_DIR/$p" + fi + done + [[ -f "$PROJECT_DIR/.env.local" ]] && cp -a "$PROJECT_DIR/.env.local" "$SAFETY_DIR/.env.local" + + log "Copie des fichiers depuis l'archive…" + # rsync si dispo (delta + permissions), sinon cp -a + if command -v rsync >/dev/null 2>&1; then + rsync -a "$WORK_DIR/files/" "$PROJECT_DIR/" + else + cp -a "$WORK_DIR/files/." "$PROJECT_DIR/" + fi + ok "Fichiers restaurés" +fi + +# --- Restauration Redis ------------------------------------------------------ +if [[ $SKIP_REDIS -eq 0 && -f "$WORK_DIR/redis.rdb" ]]; then + REDIS_DSN="$(grep -E '^REDIS_DSN=' "$ENV_FILE" | tail -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'$//")" + rre='^redis://(([^@]*)@)?([^:/]+)(:([0-9]+))?' + if [[ -n "$REDIS_DSN" && "$REDIS_DSN" =~ $rre ]]; then + R_PASS="${BASH_REMATCH[2]}" + R_HOST="${BASH_REMATCH[3]}" + R_PORT="${BASH_REMATCH[5]:-6379}" + log "Restauration Redis ($R_HOST:$R_PORT)…" + if command -v redis-cli >/dev/null 2>&1; then + REDIS_AUTH=() + [[ -n "$R_PASS" ]] && REDIS_AUTH=(-a "$R_PASS" --no-auth-warning) + redis-cli -h "$R_HOST" -p "$R_PORT" "${REDIS_AUTH[@]}" FLUSHALL >/dev/null || true + # Restaure clé par clé via DEBUG RELOAD impossible à distance ; + # on se contente d'un FLUSHALL : la majorité des données Redis (cache, sessions) + # se reconstruit à l'usage. Le RDB reste dans l'archive si besoin manuel. + warn "Redis vidé. Le fichier redis.rdb reste dans $WORK_DIR pour usage manuel." + warn "Pour réinjecter : copier redis.rdb dans le datadir Redis et redémarrer." + cp "$WORK_DIR/redis.rdb" "$SAFETY_DIR/redis.rdb" + else + warn "redis-cli absent, étape Redis ignorée" + fi + fi +fi + +# --- Tâches Symfony post-restauration --------------------------------------- +log "Tâches Symfony post-restauration…" +if [[ -x "$PROJECT_DIR/bin/console" ]]; then + php "$PROJECT_DIR/bin/console" cache:clear --no-interaction || warn "cache:clear KO" + php "$PROJECT_DIR/bin/console" doctrine:migrations:migrate --no-interaction --allow-no-migration || warn "migrate KO" + ok "Console Symfony OK" +else + warn "bin/console introuvable, étape ignorée" +fi + +cat <