#!/bin/bash # ============================================================================= # docker-backup.sh — Backup complet d'une app Docker (DB + volumes + config) # ============================================================================= # À placer dans /usr/local/bin/ ou ~/scripts/ # Cron recommandé : 0 2 * * * /usr/local/bin/docker-backup.sh # # Ce script sauvegarde : # 1. La base de données MySQL (dump SQL complet) # 2. Les volumes Docker (data, mysql) # 3. Le fichier docker-compose.yml et la config # 4. Génère un script restore.sh autonome # # Paramètres configurables : voir docker-scripts.conf # Utilisation avec autre app : DOCKER_APP_NAME=monapp bash docker-backup.sh # ============================================================================= set -euo pipefail # ─── Charger la configuration commune ───────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_FILE="${CONFIG_FILE:-$SCRIPT_DIR/docker-scripts.conf}" if [[ -f "$CONFIG_FILE" ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" fi # ─── Nom de l'application (défaut si pas dans la config) ───────────────────── DOCKER_APP_NAME="${DOCKER_APP_NAME:-gitea}" # ─── Configuration locale (après chargement conf, permet surcharge env) ────── BACKUP_DIR="${BACKUP_DIR:-/NFS/BACKUP/DOCKER/$HOSTNAME/$DOCKER_APP_NAME}" COMPOSE_DIR="${COMPOSE_DIR:-$HOME/dev/git/outils/$DOCKER_APP_NAME}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" # Conteneurs (dérivés de DOCKER_APP_NAME) DB_CONTAINER="${DB_CONTAINER:-${DOCKER_APP_NAME}_db}" APP_CONTAINER="${APP_CONTAINER:-${DOCKER_APP_NAME}_server}" DB_SERVICE="${DB_SERVICE:-${DOCKER_APP_NAME}_db}" APP_SERVICE="${APP_SERVICE:-${DOCKER_APP_NAME}_server}" # Base de données (dérivés de DOCKER_APP_NAME) DB_NAME="${DB_NAME:-$DOCKER_APP_NAME}" DB_USER="${DB_USER:-$DOCKER_APP_NAME}" DB_PASSWORD="${DB_PASSWORD:-chab30}" DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-$DOCKER_APP_NAME}" # Volumes Docker - chemins HÔTE (dérivés de DOCKER_APP_NAME) VOLUME_DATA="${VOLUME_DATA:-/srv/docker/$DOCKER_APP_NAME/data}" VOLUME_MYSQL="${VOLUME_MYSQL:-/srv/docker/$DOCKER_APP_NAME/mysql}" # Rétention RETENTION_DAYS="${RETENTION_DAYS:-30}" RETENTION_WEEKLY="${RETENTION_WEEKLY:-4}" RETENTION_MONTHLY="${RETENTION_MONTHLY:-6}" # Options COMPRESS="${COMPRESS:-true}" VERIFY_BACKUP="${VERIFY_BACKUP:-true}" STOP_CONTAINERS="${STOP_CONTAINERS:-false}" ENABLE_NOTIFICATIONS="${ENABLE_NOTIFICATIONS:-false}" # Log LOG_FILE="${LOG_FILE:-/var/log/${DOCKER_APP_NAME}-backup.log}" LOG_DIR="$(dirname "$LOG_FILE")" # Lock file LOCK_DIR="${LOCK_DIR:-/tmp}" LOCK_FILE="${LOCK_DIR}/${DOCKER_APP_NAME}-backup.lock" # Backup distant REMOTE_BACKUP_ENABLED="${REMOTE_BACKUP_ENABLED:-false}" REMOTE_BACKUP_METHOD="${REMOTE_BACKUP_METHOD:-rsync}" REMOTE_BACKUP_HOST="${REMOTE_BACKUP_HOST:-}" REMOTE_BACKUP_USER="${REMOTE_BACKUP_USER:-}" REMOTE_BACKUP_PATH="${REMOTE_BACKUP_PATH:-}" REMOTE_BACKUP_SSH_KEY="${REMOTE_BACKUP_SSH_KEY:-$HOME/.ssh/id_rsa}" # Notifications NOTIFY_METHOD="${NOTIFY_METHOD:-log}" TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}" TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-}" NTPY_TOPIC="${NTPY_TOPIC:-}" # ─── Prérequis : s'assurer que le dossier de log existe ────────────────────── mkdir -p "$LOG_DIR" 2>/dev/null || true # ─── Logging ───────────────────────────────────────────────────────────────── log() { local msg="[$(date '+%Y-%m-%d %H:%M:%S')] $1" echo "$msg" echo "$msg" >> "$LOG_FILE" 2>/dev/null || true } # ─── Notification ──────────────────────────────────────────────────────────── notify() { local msg="$1" case "$NOTIFY_METHOD" in telegram) if [[ -n "$TELEGRAM_BOT_TOKEN" ]] && [[ -n "$TELEGRAM_CHAT_ID" ]]; then curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -d "chat_id=$TELEGRAM_CHAT_ID" -d "text=$msg" > /dev/null 2>&1 || true fi ;; ntfy) if [[ -n "$NTPY_TOPIC" ]]; then curl -s -H "Title: ${DOCKER_APP_NAME^} Backup" -d "$msg" "https://ntfy.sh/$NTPY_TOPIC" > /dev/null 2>&1 || true fi ;; esac } # ─── Lock file ─────────────────────────────────────────────────────────────── acquire_lock() { if [[ -f "$LOCK_FILE" ]]; then local pid pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "") if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then log "⚠️ Une autre instance (PID $pid) est déjà en cours d'exécution — arrêt" exit 0 fi log "⚠️ Lock stale détecté (PID $pid n'existe plus), on continue" rm -f "$LOCK_FILE" fi echo $$ > "$LOCK_FILE" } release_lock() { rm -f "$LOCK_FILE" } cleanup() { release_lock } trap cleanup EXIT # ─── Détection docker compose ──────────────────────────────────────────────── detect_compose() { if docker compose version &>/dev/null; then echo "docker compose" elif command -v docker-compose &>/dev/null; then echo "docker-compose" else echo "" fi } # ─── Vérification espace disque (robuste) ──────────────────────────────────── check_disk_space() { local path="$1" local min_kb="${2:-2097152}" # 2 GB par défaut local available_kb # Méthode robuste : utiliser df -P (format POSIX, pas de wrapping) available_kb=$(df -P "$path" 2>/dev/null | tail -1 | awk '{print $4}') if [[ -z "$available_kb" || "$available_kb" -le 0 ]]; then # Fallback avec --output (GNU df) available_kb=$(df --output=avail "$path" 2>/dev/null | tail -1 | tr -d ' ' || echo "0") fi if [[ "$available_kb" -lt "$min_kb" ]]; then return 1 fi echo "$available_kb" return 0 } # ─── Backup distant ────────────────────────────────────────────────────────── sync_remote() { local backup_path="$1" if [[ "$REMOTE_BACKUP_ENABLED" != "true" ]]; then return 0 fi if [[ -z "$REMOTE_BACKUP_HOST" ]] || [[ -z "$REMOTE_BACKUP_USER" ]] || [[ -z "$REMOTE_BACKUP_PATH" ]]; then log "⚠️ Backup distant : configuration incomplète, ignoré" return 0 fi log "☁️ Synchronisation distante ($REMOTE_BACKUP_METHOD) vers ${REMOTE_BACKUP_USER}@${REMOTE_BACKUP_HOST}:${REMOTE_BACKUP_PATH}..." local ssh_opts="-o StrictHostKeyChecking=no -o ConnectTimeout=10" if [[ -f "$REMOTE_BACKUP_SSH_KEY" ]]; then ssh_opts="$ssh_opts -i $REMOTE_BACKUP_SSH_KEY" fi case "$REMOTE_BACKUP_METHOD" in rsync) if command -v rsync &>/dev/null; then rsync -avz -e "ssh $ssh_opts" \ "$backup_path" \ "${REMOTE_BACKUP_USER}@${REMOTE_BACKUP_HOST}:${REMOTE_BACKUP_PATH}/" 2>&1 || { log "⚠️ Échec rsync distant" return 1 } log " ✅ Synchronisation rsync réussie" else log "⚠️ rsync non installé, backup distant ignoré" return 1 fi ;; scp) scp $ssh_opts -r "$backup_path" \ "${REMOTE_BACKUP_USER}@${REMOTE_BACKUP_HOST}:${REMOTE_BACKUP_PATH}/" 2>&1 || { log "⚠️ Échec SCP distant" return 1 } log " ✅ Synchronisation SCP réussie" ;; *) log "⚠️ Méthode de backup distant inconnue : $REMOTE_BACKUP_METHOD" return 1 ;; esac return 0 } # ═════════════════════════════════════════════════════════════════════════════ # MAIN # ═════════════════════════════════════════════════════════════════════════════ acquire_lock TIMESTAMP=$(date +%Y%m%d-%H%M%S) DATE_LABEL=$(date +%Y-%m-%d) DAY_OF_WEEK=$(date +%u) # 1=lundi, 7=dimanche DAY_OF_MONTH=$(date +%d) BACKUP_NAME="${DOCKER_APP_NAME}-backup-${TIMESTAMP}" BACKUP_PATH="${BACKUP_DIR}/${BACKUP_NAME}" RESTORE_SCRIPT="${BACKUP_PATH}/restore.sh" DOCKER_COMPOSE=$(detect_compose) log "════════════════════════════════════════════════════════" log "📦 Début backup ${DOCKER_APP_NAME^} → $BACKUP_PATH" # ─── Pré-vérifications ────────────────────────────────────────────────────── mkdir -p "$BACKUP_DIR" || { log "❌ Impossible de créer $BACKUP_DIR"; exit 1; } cd "$COMPOSE_DIR" || { log "❌ Répertoire $COMPOSE_DIR introuvable"; exit 1; } if [[ -z "$DOCKER_COMPOSE" ]]; then log "❌ Ni 'docker compose' ni 'docker-compose' n'est disponible" exit 1 fi mkdir -p "$BACKUP_PATH" # Vérifier espace disque (minimum 2 GB) AVAILABLE_KB=$(check_disk_space "$BACKUP_DIR" 2097152) || { log "❌ Espace disque insuffisant (moins de 2 GB disponibles sur $BACKUP_DIR)" exit 1 } log "💾 Espace disque : $((AVAILABLE_KB / 1024)) MB disponibles" # ─── Optionnel : arrêter les conteneurs pour un backup consistant ─────────── CONTAINERS_WERE_STOPPED=false if [[ "$STOP_CONTAINERS" == "true" ]]; then log "⏸️ Arrêt temporaire des conteneurs..." $DOCKER_COMPOSE -f "$COMPOSE_FILE" stop 2>&1 | tee -a "$LOG_FILE" || true CONTAINERS_WERE_STOPPED=true fi # ─── 1. Backup base de données MySQL ──────────────────────────────────────── log "🛢️ Dump base de données MySQL ($DB_NAME)..." if docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then docker exec "$DB_CONTAINER" mysqldump \ -u root -p"$DB_ROOT_PASSWORD" \ --single-transaction \ --routines \ --triggers \ --events \ --hex-blob \ --default-character-set=utf8mb4 \ "$DB_NAME" > "${BACKUP_PATH}/${DOCKER_APP_NAME}-db.sql" 2>"${BACKUP_PATH}/mysqldump.err" || { log "❌ Échec du dump MySQL" cat "${BACKUP_PATH}/mysqldump.err" >> "$LOG_FILE" 2>/dev/null || true [[ "$CONTAINERS_WERE_STOPPED" == "true" ]] && $DOCKER_COMPOSE -f "$COMPOSE_FILE" start 2>&1 | tee -a "$LOG_FILE" || true exit 1 } rm -f "${BACKUP_PATH}/mysqldump.err" DB_SIZE=$(wc -c < "${BACKUP_PATH}/${DOCKER_APP_NAME}-db.sql" 2>/dev/null || echo "0") log " ✅ Dump MySQL OK (${DB_SIZE} bytes)" else log "❌ Conteneur $DB_CONTAINER non trouvé (pas en cours d'exécution)" [[ "$CONTAINERS_WERE_STOPPED" == "true" ]] && $DOCKER_COMPOSE -f "$COMPOSE_FILE" start 2>&1 | tee -a "$LOG_FILE" || true exit 1 fi # Compresser le dump si demandé if [[ "$COMPRESS" == "true" ]]; then gzip -f "${BACKUP_PATH}/${DOCKER_APP_NAME}-db.sql" log " 📦 Compressé : $(ls -lh "${BACKUP_PATH}/${DOCKER_APP_NAME}-db.sql.gz" 2>/dev/null | awk '{print $5}')" fi # ─── 2. Backup volumes Docker ─────────────────────────────────────────────── log "📁 Backup volume data ($VOLUME_DATA)..." tar -czf "${BACKUP_PATH}/${DOCKER_APP_NAME}-data.tar.gz" \ -C "$(dirname "$VOLUME_DATA")" \ --exclude='*.log' \ --exclude='log/*' \ --exclude='tmp/*' \ --exclude='cache/*' \ "$(basename "$VOLUME_DATA")" 2>&1 || { log "❌ Échec tar du volume data" [[ "$CONTAINERS_WERE_STOPPED" == "true" ]] && $DOCKER_COMPOSE -f "$COMPOSE_FILE" start 2>&1 | tee -a "$LOG_FILE" || true exit 1 } DATA_SIZE=$(ls -lh "${BACKUP_PATH}/${DOCKER_APP_NAME}-data.tar.gz" 2>/dev/null | awk '{print $5}') log " ✅ Volume data sauvegardé ($DATA_SIZE)" log "📁 Backup volume MySQL ($VOLUME_MYSQL)..." tar -czf "${BACKUP_PATH}/${DOCKER_APP_NAME}-mysql.tar.gz" \ -C "$(dirname "$VOLUME_MYSQL")" \ "$(basename "$VOLUME_MYSQL")" 2>&1 || { log "❌ Échec tar du volume MySQL" [[ "$CONTAINERS_WERE_STOPPED" == "true" ]] && $DOCKER_COMPOSE -f "$COMPOSE_FILE" start 2>&1 | tee -a "$LOG_FILE" || true exit 1 } MYSQL_SIZE=$(ls -lh "${BACKUP_PATH}/${DOCKER_APP_NAME}-mysql.tar.gz" 2>/dev/null | awk '{print $5}') log " ✅ Volume MySQL sauvegardé ($MYSQL_SIZE)" # ─── 3. Sauvegarde docker-compose + config ────────────────────────────────── log "📋 Sauvegarde docker-compose et configuration..." cp "$COMPOSE_DIR/$COMPOSE_FILE" "${BACKUP_PATH}/docker-compose.yml" # Sauvegarder la config applicative si présente (app.ini ou équivalent) APP_CONFIG_FOUND=false for config_path in "${VOLUME_DATA}/${DOCKER_APP_NAME}/conf/app.ini" "${VOLUME_DATA}/conf/app.ini" "${VOLUME_DATA}/app.ini"; do if [[ -f "$config_path" ]]; then cp "$config_path" "${BACKUP_PATH}/app.ini" log " ✅ app.ini sauvegardé depuis $config_path" APP_CONFIG_FOUND=true break fi done if [[ "$APP_CONFIG_FOUND" != "true" ]]; then log " ⚠️ app.ini non trouvé (cherché dans ${VOLUME_DATA}/)" fi # ─── Redémarrer les conteneurs si on les avait arrêtés ────────────────────── if [[ "$CONTAINERS_WERE_STOPPED" == "true" ]]; then log "▶️ Redémarrage des conteneurs..." $DOCKER_COMPOSE -f "$COMPOSE_FILE" start 2>&1 | tee -a "$LOG_FILE" || true # Attendre que l'app réponde for i in $(seq 1 30); do if curl -s -o /dev/null -w "%{http_code}" "${HEALTH_CHECK_URL:-http://localhost:3000}" 2>/dev/null | grep -qE "200|302|303"; then log "✅ ${DOCKER_APP_NAME^} répond après ${i}s" break fi sleep 1 done fi # ─── 4. Générer le script de restore ──────────────────────────────────────── log "📝 Génération du script de restore..." DB_DUMP_FILE="${DOCKER_APP_NAME}-db.sql" [[ "$COMPRESS" == "true" ]] && DB_DUMP_FILE="${DOCKER_APP_NAME}-db.sql.gz" cat > "$RESTORE_SCRIPT" << 'RESTORE_SCRIPT_EOF' #!/bin/bash # ============================================================================= # RESTORE SCRIPT — Backup __DOCKER_APP_NAME__ __TIMESTAMP__ # Généré le __GEN_DATE__ # ============================================================================= # Usage : bash restore.sh [OPTIONS] # # Options : # --dry-run Affiche ce qui serait fait sans exécuter # --force Ne pas demander confirmation # --help Cette aide # ============================================================================= set -euo pipefail # ─── Configuration cible ──────────────────────────────────────────────────── DOCKER_APP_NAME="${DOCKER_APP_NAME:-__DOCKER_APP_NAME__}" TARGET_COMPOSE_DIR="${TARGET_COMPOSE_DIR:-/srv/docker/$DOCKER_APP_NAME}" TARGET_VOLUME_DATA="${TARGET_VOLUME_DATA:-/srv/docker/$DOCKER_APP_NAME/data}" TARGET_VOLUME_MYSQL="${TARGET_VOLUME_MYSQL:-/srv/docker/$DOCKER_APP_NAME/mysql}" DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-$DOCKER_APP_NAME}" DB_NAME="${DB_NAME:-$DOCKER_APP_NAME}" DB_USER="${DB_USER:-$DOCKER_APP_NAME}" DRY_RUN=false FORCE=false for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=true ;; --force) FORCE=true ;; --help|-h) head -50 "$0"; exit 0 ;; esac done SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" BACKUP_DIR="$SCRIPT_DIR" DB_CONTAINER="${DOCKER_APP_NAME}_db" echo "════════════════════════════════════════════════════════" echo "🔄 RESTAURATION ${DOCKER_APP_NAME^^}" echo " Source : $BACKUP_DIR" echo " Date : __TIMESTAMP__" echo " Cible : $TARGET_COMPOSE_DIR" echo "════════════════════════════════════════════════════════" if [[ "$DRY_RUN" == "true" ]]; then echo "" echo "🔍 DRY RUN — aucune action ne sera effectuée" echo "" echo "Actions qui seraient exécutées :" echo " 1. mkdir -p $TARGET_VOLUME_DATA $TARGET_VOLUME_MYSQL" echo " 2. Extraire ${DOCKER_APP_NAME}-mysql.tar.gz → $TARGET_VOLUME_MYSQL" echo " 3. Extraire ${DOCKER_APP_NAME}-data.tar.gz → $TARGET_VOLUME_DATA" echo " 4. Copier docker-compose.yml → $TARGET_COMPOSE_DIR/" echo " 5. docker compose up -d" echo " 6. Attendre MySQL prêt" echo " 7. docker exec ${DB_CONTAINER} mysql ... < __DB_DUMP_FILE__" echo " 8. docker compose restart" echo "" exit 0 fi if [[ "$FORCE" != "true" ]]; then echo "" echo "⚠️ ATTENTION : Ceci va ÉCRASER les données ${DOCKER_APP_NAME} existantes !" echo " Toute donnée non sauvegardée sera PERDUE." echo "" read -p "Confirmer la restauration ? (taper 'RESTORE' en majuscules) : " CONFIRM if [[ "$CONFIRM" != "RESTORE" ]]; then echo "❌ Restauration annulée." exit 0 fi fi echo "" echo "📦 Étape 1/6 : Création des répertoires..." mkdir -p "$TARGET_VOLUME_DATA" "$TARGET_VOLUME_MYSQL" "$TARGET_COMPOSE_DIR" echo "📦 Étape 2/6 : Restauration volume MySQL..." rm -rf "${TARGET_VOLUME_MYSQL:?}"/* tar -xzf "$BACKUP_DIR/${DOCKER_APP_NAME}-mysql.tar.gz" -C "$(dirname "$TARGET_VOLUME_MYSQL")" echo "📦 Étape 3/6 : Restauration volume ${DOCKER_APP_NAME}..." rm -rf "${TARGET_VOLUME_DATA:?}"/* tar -xzf "$BACKUP_DIR/${DOCKER_APP_NAME}-data.tar.gz" -C "$(dirname "$TARGET_VOLUME_DATA")" echo "📦 Étape 4/6 : Copie docker-compose..." cp "$BACKUP_DIR/docker-compose.yml" "$TARGET_COMPOSE_DIR/" # Restaurer app.ini si présent if [[ -f "$BACKUP_DIR/app.ini" ]]; then mkdir -p "${TARGET_VOLUME_DATA}/${DOCKER_APP_NAME}/conf" cp "$BACKUP_DIR/app.ini" "${TARGET_VOLUME_DATA}/${DOCKER_APP_NAME}/conf/app.ini" echo " ✅ app.ini restauré" fi # Détecter docker compose ou docker-compose if docker compose version &>/dev/null; then DC="docker compose" elif command -v docker-compose &>/dev/null; then DC="docker-compose" else echo "❌ Ni 'docker compose' ni 'docker-compose' n'est disponible" exit 1 fi echo "📦 Étape 5/6 : Démarrage des conteneurs..." cd "$TARGET_COMPOSE_DIR" $DC up -d echo " ⏳ Attente démarrage MySQL (30s)..." sleep 10 MYSQL_READY=false for i in $(seq 1 20); do if docker exec "$DB_CONTAINER" mysqladmin ping -u root -p"$DB_ROOT_PASSWORD" --silent 2>/dev/null; then echo " ✅ MySQL prêt après $((10 + i))s" MYSQL_READY=true break fi sleep 1 done if [[ "$MYSQL_READY" != "true" ]]; then echo "⚠️ MySQL n'a pas répondu dans le temps imparti, tentative de restore DB quand même..." fi echo "📦 Étape 6/6 : Restauration base de données..." # Créer la base si elle n'existe pas docker exec "$DB_CONTAINER" mysql -u root -p"$DB_ROOT_PASSWORD" -e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" 2>/dev/null || true DB_DUMP="$BACKUP_DIR/__DB_DUMP_FILE__" if [[ -f "${DB_DUMP}" ]]; then if [[ "$DB_DUMP" == *.gz ]]; then gunzip -c "$DB_DUMP" | docker exec -i "$DB_CONTAINER" mysql -u root -p"$DB_ROOT_PASSWORD" "$DB_NAME" else docker exec -i "$DB_CONTAINER" mysql -u root -p"$DB_ROOT_PASSWORD" "$DB_NAME" < "$DB_DUMP" fi else echo "❌ Fichier dump introuvable : $DB_DUMP" exit 1 fi echo "" echo "🔄 Redémarrage final des conteneurs..." $DC restart echo "" echo "════════════════════════════════════════════════════════" echo "✅ RESTAURATION TERMINÉE !" echo "" echo " ${DOCKER_APP_NAME^} est maintenant opérationnel" echo " Vérifiez les logs : $DC logs -f" echo "════════════════════════════════════════════════════════" RESTORE_SCRIPT_EOF # Remplacer les placeholders dans le script généré sed -i "s/__DOCKER_APP_NAME__/$DOCKER_APP_NAME/g" "$RESTORE_SCRIPT" sed -i "s/__TIMESTAMP__/$TIMESTAMP/g" "$RESTORE_SCRIPT" sed -i "s/__GEN_DATE__/$(date)/g" "$RESTORE_SCRIPT" sed -i "s/__DB_DUMP_FILE__/$DB_DUMP_FILE/g" "$RESTORE_SCRIPT" chmod +x "$RESTORE_SCRIPT" log " ✅ Script restore créé : restore.sh" # ─── 5. Vérification intégrité ────────────────────────────────────────────── if [[ "$VERIFY_BACKUP" == "true" ]]; then log "🔍 Vérification intégrité des archives..." ERRORS=0 # Vérifier chaque archive .tar.gz (avec gestion du glob vide) shopt -s nullglob for ARCHIVE in "$BACKUP_PATH"/*.tar.gz; do if ! tar -tzf "$ARCHIVE" > /dev/null 2>&1; then log " ❌ Archive corrompue : $(basename "$ARCHIVE")" ERRORS=$((ERRORS + 1)) fi done shopt -u nullglob if [[ "$COMPRESS" == "true" ]]; then if ! gunzip -t "${BACKUP_PATH}/${DOCKER_APP_NAME}-db.sql.gz" 2>/dev/null; then log " ❌ Dump SQL corrompu" ERRORS=$((ERRORS + 1)) fi fi if [[ $ERRORS -gt 0 ]]; then log "❌ $ERRORS erreur(s) d'intégrité détectée(s)" exit 1 fi log " ✅ Toutes les archives sont valides" fi # ─── 6. Métadonnées du backup ─────────────────────────────────────────────── APP_VERSION="inconnue" if docker ps --format '{{.Names}}' | grep -q "^${APP_CONTAINER}$"; then APP_VERSION=$(docker inspect "$APP_CONTAINER" --format '{{.Config.Image}}' 2>/dev/null || echo "inconnue") fi DB_DISPLAY_SIZE=$(ls -lh "${BACKUP_PATH}"/${DOCKER_APP_NAME}-db.sql* 2>/dev/null | awk '{print $5}' | head -1) cat > "${BACKUP_PATH}/backup-info.txt" << INFO Date : $(date) Hôte : $(hostname) Application : ${DOCKER_APP_NAME^} App version : $APP_VERSION DB type : MySQL DB name : $DB_NAME Compression : $COMPRESS Containers stopped : $CONTAINERS_WERE_STOPPED Taille DB : ${DB_DISPLAY_SIZE:-N/A} Taille data : ${DATA_SIZE:-N/A} Taille mysql : ${MYSQL_SIZE:-N/A} INFO # ─── 7. Nettoyage des vieux backups ───────────────────────────────────────── log "🧹 Nettoyage backups (rétention: ${RETENTION_DAYS}j, ${RETENTION_WEEKLY} hebdo, ${RETENTION_MONTHLY} mensuels)..." # Pour chaque backup, déterminer s'il doit être gardé find "$BACKUP_DIR" -maxdepth 1 -type d -name "${DOCKER_APP_NAME}-backup-*" -printf '%f\n' 2>/dev/null | sort -r | while read -r backup_name; do backup_path="$BACKUP_DIR/$backup_name" # Déterminer le jour du mois et le jour de la semaine à partir du nom # Format: APP-backup-YYYYMMDD-HHMMSS local_date=$(echo "$backup_name" | grep -oE '[0-9]{8}' | head -1) if [[ -z "$local_date" ]]; then continue fi local_day=${local_date:6:2} # Jour du mois (DD) local_month=${local_date:4:2} local_year=${local_date:0:4} # Déterminer le jour de la semaine (1=lundi, 7=dimanche) local_dow=$(date -d "${local_year}-${local_month}-${local_day}" +%u 2>/dev/null || echo "0") # Âge en jours backup_ts=$(date -d "${local_year}-${local_month}-${local_day}" +%s 2>/dev/null || echo "0") now_ts=$(date +%s) age_days=$(( (now_ts - backup_ts) / 86400 )) # Décisions de rétention KEEP=false REASON="" # Toujours garder : backups récents if [[ "$age_days" -le "$RETENTION_DAYS" ]]; then KEEP=true REASON="récent (${age_days}j <= ${RETENTION_DAYS}j)" fi # Toujours garder : backups du dimanche (hebdomadaire) dans la limite RETENTION_WEEKLY if [[ "$local_dow" == "7" ]]; then WEEKLY_PATTERN="^${DOCKER_APP_NAME}-backup-[0-9]{8}" # Compter combien de backups du dimanche on garde déjà weekly_count=$(find "$BACKUP_DIR" -maxdepth 1 -type d -name "${DOCKER_APP_NAME}-backup-*" -printf '%f\n' 2>/dev/null | grep -oE "$WEEKLY_PATTERN" | while read -r wb; do wb_date=$(echo "$wb" | grep -oE '[0-9]{8}') wb_dow=$(date -d "${wb_date:0:4}-${wb_date:4:2}-${wb_date:6:2}" +%u 2>/dev/null || echo "0") if [[ "$wb_dow" == "7" ]]; then echo "$wb" fi done | sort -r | head -n "$RETENTION_WEEKLY" | wc -l) # Si ce backup fait partie des N derniers dimanches, on garde weekly_list=$(find "$BACKUP_DIR" -maxdepth 1 -type d -name "${DOCKER_APP_NAME}-backup-*" -printf '%f\n' 2>/dev/null | grep -oE "$WEEKLY_PATTERN" | while read -r wb; do wb_date=$(echo "$wb" | grep -oE '[0-9]{8}') wb_dow=$(date -d "${wb_date:0:4}-${wb_date:4:2}-${wb_date:6:2}" +%u 2>/dev/null || echo "0") if [[ "$wb_dow" == "7" ]]; then echo "$wb" fi done | sort -r | head -n "$RETENTION_WEEKLY") if echo "$weekly_list" | grep -qF "$backup_name"; then KEEP=true REASON="hebdomadaire (dimanche, dans les ${RETENTION_WEEKLY} derniers)" fi fi # Toujours garder : backups du 1er du mois (mensuel) dans la limite RETENTION_MONTHLY if [[ "$local_day" == "01" ]]; then MONTHLY_PATTERN="^${DOCKER_APP_NAME}-backup-[0-9]{8}" monthly_list=$(find "$BACKUP_DIR" -maxdepth 1 -type d -name "${DOCKER_APP_NAME}-backup-*" -printf '%f\n' 2>/dev/null | grep -oE "$MONTHLY_PATTERN" | while read -r mb; do mb_date=$(echo "$mb" | grep -oE '[0-9]{8}') mb_day=${mb_date:6:2} if [[ "$mb_day" == "01" ]]; then echo "$mb" fi done | sort -r | head -n "$RETENTION_MONTHLY") if echo "$monthly_list" | grep -qF "$backup_name"; then KEEP=true REASON="mensuel (1er du mois, dans les ${RETENTION_MONTHLY} derniers)" fi fi if [[ "$KEEP" == "true" ]]; then log " 💾 Gardé : $backup_name ($REASON)" else log " 🗑️ Suppression : $backup_name" rm -rf "$backup_path" fi done # ─── 8. Backup distant ────────────────────────────────────────────────────── sync_remote "$BACKUP_PATH" || true # ─── 9. Rapport final ─────────────────────────────────────────────────────── TOTAL_SIZE=$(du -sh "$BACKUP_PATH" 2>/dev/null | cut -f1) BACKUP_COUNT=$(find "$BACKUP_DIR" -maxdepth 1 -type d -name "${DOCKER_APP_NAME}-backup-*" 2>/dev/null | wc -l) log "✅ Backup terminé !" log "📊 Total : ${TOTAL_SIZE:-N/A}" log "📋 Backups conservés : $BACKUP_COUNT" log "📂 Destination : $BACKUP_PATH" log "═══ Fin ───────────────────────────────────────────" echo "" echo "📂 $BACKUP_PATH" echo " ├── ${DOCKER_APP_NAME}-db.sql.gz ← Dump MySQL" echo " ├── ${DOCKER_APP_NAME}-data.tar.gz ← Volume /data" echo " ├── ${DOCKER_APP_NAME}-mysql.tar.gz ← Volume MySQL" echo " ├── docker-compose.yml ← Stack Docker" echo " ├── app.ini ← Configuration ${DOCKER_APP_NAME^}" echo " ├── restore.sh ← Script de restore" echo " └── backup-info.txt ← Métadonnées" notify "✅ Backup ${DOCKER_APP_NAME^} terminé sur $(hostname) — $TOTAL_SIZE — $BACKUP_PATH" exit 0