commit d40af53c69d4c1ef3880bf96665ffeae7d07135f Author: Johnny Date: Fri Feb 27 08:45:11 2026 +0000 Ajouter backup.sh_redmine.sh diff --git a/backup.sh_redmine.sh b/backup.sh_redmine.sh new file mode 100644 index 0000000..949d577 --- /dev/null +++ b/backup.sh_redmine.sh @@ -0,0 +1,846 @@ +#!/usr/bin/env bash +# ============================================================================= +# redmine-backup.sh — Sauvegarde & Restauration complète de Redmine +# Version : 2.0.0 +# Date : 2026-02-26 +# Usage : sudo ./redmine-backup.sh [backup|restore|list|help] +# ============================================================================= + +set -euo pipefail +IFS=$'\n\t' + +# ── Couleurs ────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +# ── Répertoire du script ────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" +LOG_FILE="/var/log/redmine-backup.log" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" + +# ============================================================================= +# FONCTIONS UTILITAIRES +# ============================================================================= + +log() { + local level="$1"; shift + local msg="$*" + local ts; ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo -e "${ts} [${level}] ${msg}" | tee -a "${LOG_FILE}" >/dev/null + case "${level}" in + INFO) echo -e "${GREEN}[INFO]${RESET} ${msg}" ;; + WARN) echo -e "${YELLOW}[WARN]${RESET} ${msg}" ;; + ERROR) echo -e "${RED}[ERROR]${RESET} ${msg}" ;; + STEP) echo -e "${CYAN}[STEP]${RESET} ${BOLD}${msg}${RESET}" ;; + SUCCESS) echo -e "${GREEN}[OK]${RESET} ${msg}" ;; + esac +} + +die() { + log ERROR "$*" + exit 1 +} + +require_root() { + [[ "${EUID}" -eq 0 ]] || die "Ce script doit être exécuté en tant que root (sudo)." +} + +require_command() { + command -v "$1" &>/dev/null || die "Commande requise introuvable : $1" +} + +confirm() { + local prompt="${1:-Continuer ?} [o/N] " + read -rp "$(echo -e "${YELLOW}${prompt}${RESET}")" answer + [[ "${answer,,}" =~ ^(o|oui|y|yes)$ ]] +} + +spinner() { + local pid=$1; local msg="${2:-Traitement en cours...}" + local spin='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' + local i=0 + while kill -0 "${pid}" 2>/dev/null; do + printf "\r${CYAN}%s${RESET} %s" "${spin:$((i % ${#spin})):1}" "${msg}" + ((i++)); sleep 0.1 + done + printf "\r%-60s\r" " " +} + +hr() { echo -e "${BLUE}$(printf '─%.0s' {1..70})${RESET}"; } + +# ============================================================================= +# CHARGEMENT DE LA CONFIGURATION +# ============================================================================= + +load_env() { + [[ -f "${ENV_FILE}" ]] || die "Fichier .env introuvable : ${ENV_FILE}" + + # Valeurs par défaut + REDMINE_ROOT="" + BACKUP_DIR="/opt/Backups" + RAILS_ENV="production" + DB_CONFIG="" + DB_TYPE="" + KEEP_BACKUPS=7 + INCLUDE_LOG="false" + + # Chargement sécurisé (ignore commentaires et lignes vides) + while IFS='=' read -r key value; do + [[ "${key}" =~ ^[[:space:]]*# ]] && continue + [[ -z "${key// /}" ]] && continue + key="${key// /}" + value="${value%%#*}" # supprime commentaires inline + value="${value%"${value##*[![:space:]]}"}" # trim trailing spaces + value="${value#\"}" ; value="${value%\"}" # supprime guillemets doubles + value="${value#\'}" ; value="${value%\'}" # supprime guillemets simples + export "${key}=${value}" 2>/dev/null || true + done < "${ENV_FILE}" + + # Vérifications obligatoires + [[ -n "${REDMINE_ROOT}" ]] || die "REDMINE_ROOT n'est pas défini dans .env" + [[ -d "${REDMINE_ROOT}" ]] || die "REDMINE_ROOT introuvable : ${REDMINE_ROOT}" + + mkdir -p "${BACKUP_DIR}" || die "Impossible de créer BACKUP_DIR : ${BACKUP_DIR}" + touch "${LOG_FILE}" 2>/dev/null || LOG_FILE="/tmp/redmine-backup.log" +} + +# ============================================================================= +# DÉTECTION DE LA BASE DE DONNÉES +# ============================================================================= + +detect_db() { + local db_config="${DB_CONFIG:-${REDMINE_ROOT}/config/database.yml}" + [[ -f "${db_config}" ]] || die "Fichier database.yml introuvable : ${db_config}" + + # Extraction de la section correspondant à RAILS_ENV + local section + section=$(awk "/^${RAILS_ENV}:/,/^[a-z]/" "${db_config}" | head -n -1) + + DB_ADAPTER="${DB_TYPE}" + if [[ -z "${DB_ADAPTER}" ]]; then + DB_ADAPTER=$(echo "${section}" | grep -E '^\s+adapter:' | awk '{print $2}' | tr -d '"' | head -1) + fi + DB_NAME=$(echo "${section}" | grep -E '^\s+database:' | awk '{print $2}' | tr -d '"' | head -1) + DB_HOST=$(echo "${section}" | grep -E '^\s+host:' | awk '{print $2}' | tr -d '"' | head -1) + DB_PORT=$(echo "${section}" | grep -E '^\s+port:' | awk '{print $2}' | tr -d '"' | head -1) + DB_USER=$(echo "${section}" | grep -E '^\s+username:' | awk '{print $2}' | tr -d '"' | head -1) + DB_PASS=$(echo "${section}" | grep -E '^\s+password:' | awk '{print $2}' | tr -d '"' | head -1) + + DB_HOST="${DB_HOST:-localhost}" + DB_PORT="${DB_PORT:-}" + + [[ -n "${DB_ADAPTER}" ]] || die "Impossible de détecter l'adaptateur DB dans database.yml" + [[ -n "${DB_NAME}" ]] || die "Impossible de détecter le nom de la base de données" +} + +# ============================================================================= +# FONCTIONS DE SAUVEGARDE DE LA BASE DE DONNÉES +# ============================================================================= + +backup_db_postgresql() { + log STEP "Sauvegarde PostgreSQL → ${DB_NAME}" + local dump_file="${TMP_DIR}/database.sql" + local pg_opts=(-U "${DB_USER}" -h "${DB_HOST}") + [[ -n "${DB_PORT}" ]] && pg_opts+=(-p "${DB_PORT}") + + if [[ -n "${DB_PASS}" ]]; then + PGPASSWORD="${DB_PASS}" pg_dump "${pg_opts[@]}" \ + --no-owner --no-acl --format=custom \ + -f "${dump_file}.custom" "${DB_NAME}" & + else + pg_dump "${pg_opts[@]}" --no-owner --no-acl --format=custom \ + -f "${dump_file}.custom" "${DB_NAME}" & + fi + spinner $! "Dump PostgreSQL en cours..." + wait $! + mv "${dump_file}.custom" "${dump_file}" + log SUCCESS "Dump PostgreSQL terminé" +} + +backup_db_mysql2() { + log STEP "Sauvegarde MySQL/MariaDB → ${DB_NAME}" + local dump_file="${TMP_DIR}/database.sql" + local my_opts=(--single-transaction --routines --triggers --events) + [[ -n "${DB_HOST}" ]] && my_opts+=(-h "${DB_HOST}") + [[ -n "${DB_PORT}" ]] && my_opts+=(-P "${DB_PORT}") + [[ -n "${DB_USER}" ]] && my_opts+=(-u "${DB_USER}") + + if [[ -n "${DB_PASS}" ]]; then + MYSQL_PWD="${DB_PASS}" mysqldump "${my_opts[@]}" "${DB_NAME}" > "${dump_file}" & + else + mysqldump "${my_opts[@]}" "${DB_NAME}" > "${dump_file}" & + fi + spinner $! "Dump MySQL en cours..." + wait $! + log SUCCESS "Dump MySQL terminé" +} + +backup_db_sqlite3() { + log STEP "Sauvegarde SQLite3 → ${DB_NAME}" + local dump_file="${TMP_DIR}/database.sql" + local sqlite_path="${DB_NAME}" + # Si chemin relatif, le résoudre depuis REDMINE_ROOT + [[ "${sqlite_path}" != /* ]] && sqlite_path="${REDMINE_ROOT}/${sqlite_path}" + [[ -f "${sqlite_path}" ]] || die "Fichier SQLite introuvable : ${sqlite_path}" + sqlite3 "${sqlite_path}" .dump > "${dump_file}" & + spinner $! "Dump SQLite3 en cours..." + wait $! + log SUCCESS "Dump SQLite3 terminé" +} + +# ============================================================================= +# FONCTIONS DE RESTAURATION DE LA BASE DE DONNÉES +# ============================================================================= + +restore_db_postgresql() { + log STEP "Restauration PostgreSQL → ${DB_NAME}" + local dump_file="${TMP_DIR}/database.sql" + local pg_opts=(-U "${DB_USER}" -h "${DB_HOST}") + [[ -n "${DB_PORT}" ]] && pg_opts+=(-p "${DB_PORT}") + + # Recrée la base si nécessaire + if [[ -n "${DB_PASS}" ]]; then + PGPASSWORD="${DB_PASS}" psql "${pg_opts[@]}" -d postgres \ + -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" 2>/dev/null || true + PGPASSWORD="${DB_PASS}" psql "${pg_opts[@]}" -d postgres \ + -c "CREATE DATABASE \"${DB_NAME}\" OWNER \"${DB_USER}\";" + PGPASSWORD="${DB_PASS}" pg_restore "${pg_opts[@]}" \ + --no-owner --no-acl -d "${DB_NAME}" "${dump_file}" & + else + psql "${pg_opts[@]}" -d postgres \ + -c "DROP DATABASE IF EXISTS \"${DB_NAME}\";" 2>/dev/null || true + psql "${pg_opts[@]}" -d postgres \ + -c "CREATE DATABASE \"${DB_NAME}\" OWNER \"${DB_USER}\";" + pg_restore "${pg_opts[@]}" --no-owner --no-acl \ + -d "${DB_NAME}" "${dump_file}" & + fi + spinner $! "Restauration PostgreSQL en cours..." + wait $! || log WARN "pg_restore a retourné des avertissements (normal si objets existants)" + log SUCCESS "Restauration PostgreSQL terminée" +} + +restore_db_mysql2() { + log STEP "Restauration MySQL/MariaDB → ${DB_NAME}" + local dump_file="${TMP_DIR}/database.sql" + local my_opts=() + [[ -n "${DB_HOST}" ]] && my_opts+=(-h "${DB_HOST}") + [[ -n "${DB_PORT}" ]] && my_opts+=(-P "${DB_PORT}") + [[ -n "${DB_USER}" ]] && my_opts+=(-u "${DB_USER}") + + if [[ -n "${DB_PASS}" ]]; then + MYSQL_PWD="${DB_PASS}" mysql "${my_opts[@]}" \ + -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + MYSQL_PWD="${DB_PASS}" mysql "${my_opts[@]}" "${DB_NAME}" < "${dump_file}" & + else + mysql "${my_opts[@]}" \ + -e "DROP DATABASE IF EXISTS \`${DB_NAME}\`; CREATE DATABASE \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql "${my_opts[@]}" "${DB_NAME}" < "${dump_file}" & + fi + spinner $! "Restauration MySQL en cours..." + wait $! + log SUCCESS "Restauration MySQL terminée" +} + +restore_db_sqlite3() { + log STEP "Restauration SQLite3 → ${DB_NAME}" + local dump_file="${TMP_DIR}/database.sql" + local sqlite_path="${DB_NAME}" + [[ "${sqlite_path}" != /* ]] && sqlite_path="${REDMINE_ROOT}/${sqlite_path}" + + [[ -f "${sqlite_path}" ]] && cp "${sqlite_path}" "${sqlite_path}.pre_restore_bak" + sqlite3 "${sqlite_path}" < "${dump_file}" & + spinner $! "Restauration SQLite3 en cours..." + wait $! + log SUCCESS "Restauration SQLite3 terminée" +} + +# ============================================================================= +# SAUVEGARDE PRINCIPALE +# ============================================================================= + +do_backup() { + hr + echo -e "${BOLD}${CYAN} ██████╗ █████╗ ██████╗██╗ ██╗██╗ ██╗██████╗ ${RESET}" + echo -e "${BOLD}${CYAN} ██╔══██╗██╔══██╗██╔════╝██║ ██╔╝██║ ██║██╔══██╗${RESET}" + echo -e "${BOLD}${CYAN} ██████╔╝███████║██║ █████╔╝ ██║ ██║██████╔╝${RESET}" + echo -e "${BOLD}${CYAN} ██╔══██╗██╔══██║██║ ██╔═██╗ ██║ ██║██╔═══╝ ${RESET}" + echo -e "${BOLD}${CYAN} ██████╔╝██║ ██║╚██████╗██║ ██╗╚██████╔╝██║ ${RESET}" + echo -e "${BOLD}${CYAN} ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ${RESET}" + hr + log INFO "Démarrage de la sauvegarde Redmine — ${TIMESTAMP}" + log INFO "Source : ${REDMINE_ROOT}" + log INFO "Destination : ${BACKUP_DIR}" + log INFO "Environnement Rails : ${RAILS_ENV}" + + detect_db + log INFO "Adaptateur DB détecté : ${DB_ADAPTER} / Base : ${DB_NAME}" + + # Dossier temporaire + TMP_DIR="$(mktemp -d /tmp/redmine_backup_XXXXXX)" + trap 'rm -rf "${TMP_DIR}"' EXIT + + ARCHIVE_NAME="redmine_backup_${TIMESTAMP}.tar.gz" + ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}" + + # ── 1. Dump de la base de données ───────────────────────────────────────── + case "${DB_ADAPTER}" in + postgresql) backup_db_postgresql ;; + mysql2|mysql) backup_db_mysql2 ;; + sqlite3) backup_db_sqlite3 ;; + *) die "Adaptateur DB non supporté : ${DB_ADAPTER}" ;; + esac + + # ── 2. Informations de configuration ────────────────────────────────────── + log STEP "Collecte des informations système" + { + echo "=== REDMINE BACKUP MANIFEST ===" + echo "Timestamp : ${TIMESTAMP}" + echo "Hostname : $(hostname -f)" + echo "REDMINE_ROOT : ${REDMINE_ROOT}" + echo "RAILS_ENV : ${RAILS_ENV}" + echo "DB_ADAPTER : ${DB_ADAPTER}" + echo "DB_NAME : ${DB_NAME}" + echo "DB_HOST : ${DB_HOST}" + echo "DB_USER : ${DB_USER}" + echo "" + echo "=== VERSION REDMINE ===" + [[ -f "${REDMINE_ROOT}/Gemfile.lock" ]] && \ + grep -A1 "^GEM$" "${REDMINE_ROOT}/Gemfile.lock" | head -5 || echo "N/A" + [[ -f "${REDMINE_ROOT}/app/models/setting.rb" ]] && \ + grep -E "VERSION|REVISION" "${REDMINE_ROOT}/app/models/setting.rb" 2>/dev/null | head -3 || true + [[ -f "${REDMINE_ROOT}/lib/redmine/version.rb" ]] && \ + grep -E "MAJOR|MINOR|TINY|VERSION" "${REDMINE_ROOT}/lib/redmine/version.rb" || true + echo "" + echo "=== PLUGINS INSTALLÉS ===" + if [[ -d "${REDMINE_ROOT}/plugins" ]]; then + for d in "${REDMINE_ROOT}/plugins"/*/; do + local pname; pname=$(basename "${d}") + [[ "${pname}" == "*" ]] && continue + local pver="N/A" + [[ -f "${d}/init.rb" ]] && pver=$(grep -oP "version\s*[=:>]+\s*['\"]?\K[0-9a-zA-Z._-]+" "${d}/init.rb" | head -1 || echo "N/A") + echo " - ${pname} (${pver})" + done + else + echo " Aucun plugin trouvé" + fi + echo "" + echo "=== GEMS (Gemfile.lock) ===" + [[ -f "${REDMINE_ROOT}/Gemfile.lock" ]] && \ + grep "^ [a-z]" "${REDMINE_ROOT}/Gemfile.lock" | head -50 || echo "N/A" + echo "" + echo "=== VARIABLES D'ENVIRONNEMENT SYSTÈME ===" + ruby --version 2>/dev/null || echo "ruby: N/A" + bundler --version 2>/dev/null || echo "bundler: N/A" + } > "${TMP_DIR}/manifest.txt" + log SUCCESS "Manifest créé" + + # ── 3. Copie des fichiers de configuration ───────────────────────────────── + log STEP "Sauvegarde des fichiers de configuration" + mkdir -p "${TMP_DIR}/config" + for cfg in database.yml configuration.yml email.yml secrets.yml; do + [[ -f "${REDMINE_ROOT}/config/${cfg}" ]] && \ + cp "${REDMINE_ROOT}/config/${cfg}" "${TMP_DIR}/config/" && \ + log INFO " config/${cfg} inclus" + done + # .env du script lui-même + [[ -f "${ENV_FILE}" ]] && cp "${ENV_FILE}" "${TMP_DIR}/config/.env.backup" + + # ── 4. Création de l'archive complète ──────────────────────────────────── + log STEP "Création de l'archive TAR.GZ" + + local tar_excludes=( + "--exclude=${REDMINE_ROOT}/tmp" + "--exclude=${REDMINE_ROOT}/.git" + "--exclude=${REDMINE_ROOT}/log/*.log" + ) + [[ "${INCLUDE_LOG}" != "true" ]] && tar_excludes+=("--exclude=${REDMINE_ROOT}/log") + + local tar_sources=("${REDMINE_ROOT}") + # Inclure fichiers temporaires de dump + local tmp_archive="${TMP_DIR}/files_backup.tar.gz" + + ( + tar "${tar_excludes[@]}" \ + --exclude="${REDMINE_ROOT}/tmp/cache" \ + -czf "${tmp_archive}" \ + -C "$(dirname "${REDMINE_ROOT}")" \ + "$(basename "${REDMINE_ROOT}")" 2>/dev/null + ) & + spinner $! "Archivage des fichiers Redmine..." + wait $! + log SUCCESS "Archive des fichiers créée" + + # ── 5. Assemblage de l'archive finale ───────────────────────────────────── + log STEP "Assemblage de l'archive finale" + ( + tar -czf "${ARCHIVE_PATH}" \ + -C "${TMP_DIR}" \ + database.sql \ + manifest.txt \ + config \ + -C "${TMP_DIR}" \ + files_backup.tar.gz 2>/dev/null + ) & + spinner $! "Assemblage final..." + wait $! + + local size; size=$(du -sh "${ARCHIVE_PATH}" | cut -f1) + log SUCCESS "Archive créée : ${ARCHIVE_PATH} (${size})" + + # ── 6. Rotation des sauvegardes ─────────────────────────────────────────── + log STEP "Rotation des anciennes sauvegardes (conservation : ${KEEP_BACKUPS})" + local count + count=$(find "${BACKUP_DIR}" -maxdepth 1 -name "redmine_backup_*.tar.gz" | wc -l) + if [[ "${count}" -gt "${KEEP_BACKUPS}" ]]; then + local to_delete=$(( count - KEEP_BACKUPS )) + find "${BACKUP_DIR}" -maxdepth 1 -name "redmine_backup_*.tar.gz" \ + -printf '%T+ %p\n' | sort | head -n "${to_delete}" | \ + awk '{print $2}' | while read -r old; do + rm -f "${old}" + log INFO " Supprimé : $(basename "${old}")" + done + else + log INFO " Aucune rotation nécessaire (${count}/${KEEP_BACKUPS})" + fi + + hr + echo -e "${GREEN}${BOLD} ✔ Sauvegarde terminée avec succès !${RESET}" + echo -e "${GREEN} Archive : ${ARCHIVE_PATH}${RESET}" + echo -e "${GREEN} Taille : ${size}${RESET}" + hr +} + +# ============================================================================= +# RESTAURATION PRINCIPALE +# ============================================================================= + +list_backups() { + local backups=() + while IFS= read -r f; do + backups+=("${f}") + done < <(find "${BACKUP_DIR}" -maxdepth 1 -name "redmine_backup_*.tar.gz" | sort -r) + echo "${backups[@]+"${backups[@]}"}" +} + +do_restore() { + hr + echo -e "${BOLD}${YELLOW} ██████╗ ███████╗███████╗████████╗ ██████╗ ██████╗ ███████╗${RESET}" + echo -e "${BOLD}${YELLOW} ██╔══██╗██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██╔════╝${RESET}" + echo -e "${BOLD}${YELLOW} ██████╔╝█████╗ ███████╗ ██║ ██║ ██║██████╔╝█████╗ ${RESET}" + echo -e "${BOLD}${YELLOW} ██╔══██╗██╔══╝ ╚════██║ ██║ ██║ ██║██╔══██╗██╔══╝ ${RESET}" + echo -e "${BOLD}${YELLOW} ██║ ██║███████╗███████║ ██║ ╚██████╔╝██║ ██║███████╗${RESET}" + echo -e "${BOLD}${YELLOW} ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝${RESET}" + hr + + # ── Sélection interactive de l'archive ──────────────────────────────────── + local backup_list + mapfile -t backup_list < <(find "${BACKUP_DIR}" -maxdepth 1 -name "redmine_backup_*.tar.gz" | sort -r) + + if [[ ${#backup_list[@]} -eq 0 ]]; then + die "Aucune archive de sauvegarde trouvée dans ${BACKUP_DIR}" + fi + + echo -e "\n${BOLD} Sauvegardes disponibles :${RESET}\n" + local i=1 + for archive in "${backup_list[@]}"; do + local fname; fname=$(basename "${archive}") + local fsize; fsize=$(du -sh "${archive}" | cut -f1) + local fdate; fdate=$(stat -c '%y' "${archive}" | cut -d'.' -f1) + printf " ${CYAN}[%2d]${RESET} %-45s ${YELLOW}%6s${RESET} %s\n" \ + "${i}" "${fname}" "${fsize}" "${fdate}" + i=$(( i + 1 )) + done + echo -e " ${CYAN}[ 0]${RESET} Annuler\n" + hr + + local choice + while true; do + read -rp "$(echo -e "${BOLD} Choisir une archive [0-$((i-1))] : ${RESET}")" choice + [[ "${choice}" =~ ^[0-9]+$ ]] || continue + [[ "${choice}" -eq 0 ]] && { log INFO "Restauration annulée."; return 0; } + [[ "${choice}" -ge 1 && "${choice}" -le ${#backup_list[@]} ]] && break + echo -e "${RED} Choix invalide.${RESET}" + done + + local selected="${backup_list[$((choice-1))]}" + log INFO "Archive sélectionnée : $(basename "${selected}")" + + # ── Options de restauration ──────────────────────────────────────────────── + hr + echo -e "\n${BOLD} Options de restauration :${RESET}\n" + echo -e " ${CYAN}[1]${RESET} Restauration complète (DB + fichiers + config)" + echo -e " ${CYAN}[2]${RESET} Base de données uniquement" + echo -e " ${CYAN}[3]${RESET} Fichiers uniquement (sans DB)" + echo -e " ${CYAN}[4]${RESET} Configuration uniquement" + echo -e " ${CYAN}[0]${RESET} Annuler\n" + hr + + local mode + while true; do + read -rp "$(echo -e "${BOLD} Mode de restauration [0-4] : ${RESET}")" mode + [[ "${mode}" =~ ^[0-4]$ ]] && break + echo -e "${RED} Choix invalide.${RESET}" + done + [[ "${mode}" -eq 0 ]] && { log INFO "Restauration annulée."; return 0; } + + # ── Confirmation finale ──────────────────────────────────────────────────── + hr + echo -e "\n${RED}${BOLD} ⚠ ATTENTION : Cette opération va écraser les données existantes !${RESET}" + echo -e " Archive : $(basename "${selected}")" + echo -e " Cible : ${REDMINE_ROOT}" + case "${mode}" in + 1) echo -e " Mode : ${BOLD}Restauration complète${RESET}" ;; + 2) echo -e " Mode : ${BOLD}Base de données seulement${RESET}" ;; + 3) echo -e " Mode : ${BOLD}Fichiers seulement${RESET}" ;; + 4) echo -e " Mode : ${BOLD}Configuration seulement${RESET}" ;; + esac + echo "" + confirm " Confirmer la restauration ?" || { log INFO "Restauration annulée."; return 0; } + + detect_db + + # ── Extraction de l'archive ──────────────────────────────────────────────── + TMP_DIR="$(mktemp -d /tmp/redmine_restore_XXXXXX)" + trap 'rm -rf "${TMP_DIR}"' EXIT + + log STEP "Extraction de l'archive" + (tar -xzf "${selected}" -C "${TMP_DIR}") & + spinner $! "Extraction en cours..." + wait $! + log SUCCESS "Archive extraite dans ${TMP_DIR}" + + # Afficher le manifest + if [[ -f "${TMP_DIR}/manifest.txt" ]]; then + hr + echo -e "${BOLD} Informations de la sauvegarde :${RESET}" + head -20 "${TMP_DIR}/manifest.txt" | sed 's/^/ /' + hr + fi + + # ── Restauration base de données ────────────────────────────────────────── + if [[ "${mode}" -eq 1 || "${mode}" -eq 2 ]]; then + [[ -f "${TMP_DIR}/database.sql" ]] || die "Dump de base de données introuvable dans l'archive" + case "${DB_ADAPTER}" in + postgresql) restore_db_postgresql ;; + mysql2|mysql) restore_db_mysql2 ;; + sqlite3) restore_db_sqlite3 ;; + *) die "Adaptateur DB non supporté : ${DB_ADAPTER}" ;; + esac + fi + + # ── Restauration fichiers ───────────────────────────────────────────────── + if [[ "${mode}" -eq 1 || "${mode}" -eq 3 ]]; then + [[ -f "${TMP_DIR}/files_backup.tar.gz" ]] || \ + die "Archive des fichiers introuvable dans la sauvegarde" + log STEP "Restauration des fichiers Redmine" + local parent_dir; parent_dir="$(dirname "${REDMINE_ROOT}")" + local base_name; base_name="$(basename "${REDMINE_ROOT}")" + # Sauvegarde du dossier actuel + if [[ -d "${REDMINE_ROOT}" ]]; then + mv "${REDMINE_ROOT}" "${REDMINE_ROOT}.pre_restore_${TIMESTAMP}" && \ + log INFO "Ancienne installation sauvegardée : ${REDMINE_ROOT}.pre_restore_${TIMESTAMP}" + fi + (tar -xzf "${TMP_DIR}/files_backup.tar.gz" -C "${parent_dir}") & + spinner $! "Restauration des fichiers..." + wait $! + log SUCCESS "Fichiers restaurés dans ${REDMINE_ROOT}" + fi + + # ── Restauration configuration uniquement ──────────────────────────────── + if [[ "${mode}" -eq 4 ]]; then + [[ -d "${TMP_DIR}/config" ]] || die "Dossier config introuvable dans l'archive" + log STEP "Restauration des fichiers de configuration" + for cfg in "${TMP_DIR}/config/"*; do + local fname; fname=$(basename "${cfg}") + [[ "${fname}" == ".env.backup" ]] && continue + local dest="${REDMINE_ROOT}/config/${fname}" + cp "${cfg}" "${dest}" && log INFO " Restauré : config/${fname}" + done + log SUCCESS "Configuration restaurée" + fi + + # ── Post-restauration ───────────────────────────────────────────────────── + if [[ "${mode}" -eq 1 || "${mode}" -eq 3 ]]; then + log STEP "Post-restauration : ajustement des permissions" + local redmine_user + redmine_user=$(stat -c '%U' "${REDMINE_ROOT}" 2>/dev/null || echo "www-data") + chown -R "${redmine_user}:${redmine_user}" "${REDMINE_ROOT}" 2>/dev/null || \ + log WARN "Impossible d'ajuster les permissions (continuez manuellement)" + log SUCCESS "Permissions ajustées pour '${redmine_user}'" + + if [[ "${mode}" -eq 1 ]]; then + log INFO "Relance conseillée : bundle exec rake db:migrate RAILS_ENV=${RAILS_ENV}" + log INFO "Relance conseillée : touch ${REDMINE_ROOT}/tmp/restart.txt" + fi + fi + + hr + echo -e "${GREEN}${BOLD} ✔ Restauration terminée avec succès !${RESET}" + hr +} + +# ============================================================================= +# LISTING DES SAUVEGARDES +# ============================================================================= + +do_list() { + hr + echo -e "${BOLD} Sauvegardes disponibles dans : ${BACKUP_DIR}${RESET}\n" + local count=0 + while IFS= read -r archive; do + local fname; fname=$(basename "${archive}") + local fsize; fsize=$(du -sh "${archive}" | cut -f1) + local fdate; fdate=$(stat -c '%y' "${archive}" | cut -d'.' -f1) + printf " ${CYAN}%-50s${RESET} ${YELLOW}%6s${RESET} %s\n" "${fname}" "${fsize}" "${fdate}" + count=$(( count + 1 )) + done < <(find "${BACKUP_DIR}" -maxdepth 1 -name "redmine_backup_*.tar.gz" | sort -r) + [[ "${count}" -eq 0 ]] && echo -e " ${YELLOW}Aucune sauvegarde trouvée.${RESET}" + echo "" + echo -e " Total : ${BOLD}${count} archive(s)${RESET}" + hr +} + +# ============================================================================= +# AIDE +# ============================================================================= + +do_help() { + hr + echo -e "${BOLD} redmine-backup.sh — Outil de sauvegarde/restauration Redmine${RESET}\n" + echo -e " ${BOLD}Usage :${RESET}" + echo -e " sudo $0 backup Creer une sauvegarde complete" + echo -e " sudo $0 restore Menu interactif de restauration" + echo -e " sudo $0 list Lister les sauvegardes disponibles" + echo -e " sudo $0 cron Gerer la planification automatique" + echo -e " sudo $0 help Afficher cette aide\n" + echo -e " ${BOLD}Configuration (.env) :${RESET}" + echo -e " REDMINE_ROOT Repertoire racine de Redmine" + echo -e " BACKUP_DIR Dossier de stockage des archives" + echo -e " RAILS_ENV Environnement Rails (production)" + echo -e " DB_CONFIG Chemin custom vers database.yml (optionnel)" + echo -e " DB_TYPE Forcer l'adaptateur DB (optionnel)" + echo -e " KEEP_BACKUPS Nombre d'archives a conserver (defaut: 7)" + echo -e " INCLUDE_LOG Inclure les logs dans la sauvegarde (true/false)\n" + echo -e " ${BOLD}Adaptateurs DB supportes :${RESET}" + echo -e " postgresql, mysql2, sqlite3\n" + echo -e " ${BOLD}Exemples cron utiles :${RESET}" + echo -e " 0 2 * * * Tous les jours a 02h00" + echo -e " 0 2 * * 1 Tous les lundis a 02h00" + echo -e " 0 2 1 * * Le 1er de chaque mois a 02h00\n" + echo -e " ${BOLD}Journal :${RESET} ${LOG_FILE}" + hr +} + +# ============================================================================= +# GESTION CRON +# ============================================================================= + +do_cron() { + local script_path; script_path="$(realpath "${BASH_SOURCE[0]}")" + local cron_tag="# redmine-backup-auto" + local cron_user="root" + + hr + echo -e "${BOLD}${CYAN} Planification des sauvegardes automatiques (Cron)${RESET}" + hr + + # ── Afficher les entrées cron existantes ────────────────────────────────── + echo -e "\n${BOLD} Entrées cron actuelles pour redmine-backup :${RESET}\n" + local existing + existing=$(crontab -u "${cron_user}" -l 2>/dev/null | grep "${cron_tag}" || true) + if [[ -n "${existing}" ]]; then + echo "${existing}" | while read -r line; do + echo -e " ${YELLOW}${line}${RESET}" + done + else + echo -e " ${YELLOW}Aucune planification active.${RESET}" + fi + + echo "" + hr + echo -e "\n${BOLD} Choisir une fréquence de sauvegarde :${RESET}\n" + echo -e " ${CYAN}[1]${RESET} Quotidienne — tous les jours à 02h00" + echo -e " ${CYAN}[2]${RESET} Biquotidienne — 2x par jour à 02h00 et 14h00" + echo -e " ${CYAN}[3]${RESET} Hebdomadaire — tous les lundis à 02h00" + echo -e " ${CYAN}[4]${RESET} Mensuelle — le 1er du mois à 02h00" + echo -e " ${CYAN}[5]${RESET} Personnalisée — saisir une expression cron manuellement" + echo -e " ${CYAN}[6]${RESET} Supprimer — retirer toutes les planifications" + echo -e " ${CYAN}[0]${RESET} Retour au menu principal" + echo "" + hr + + local cron_choice + while true; do + read -rp "$(echo -e "${BOLD} Votre choix [0-6] : ${RESET}")" cron_choice + [[ "${cron_choice}" =~ ^[0-6]$ ]] && break + echo -e "${RED} Choix invalide.${RESET}" + done + + [[ "${cron_choice}" -eq 0 ]] && return 0 + + # ── Suppression ──────────────────────────────────────────────────────────── + if [[ "${cron_choice}" -eq 6 ]]; then + local current_cron + # Supprime la ligne cron ET la ligne MAILTO qui la précède si elle existe + current_cron=$(crontab -u "${cron_user}" -l 2>/dev/null \ + | grep -v "${cron_tag}" \ + || true) + if [[ -n "${current_cron}" ]]; then + echo "${current_cron}" | crontab -u "${cron_user}" - + else + crontab -u "${cron_user}" - <<< "" + fi + log SUCCESS "Toutes les planifications redmine-backup ont ete supprimees" + return 0 + fi + + # ── Construction de l'expression cron ───────────────────────────────────── + local cron_expr="" + case "${cron_choice}" in + 1) cron_expr="0 2 * * *" ;; + 2) cron_expr="0 2,14 * * *" ;; + 3) cron_expr="0 2 * * 1" ;; + 4) cron_expr="0 2 1 * *" ;; + 5) + echo -e "\n ${BOLD}Format :${RESET} minute heure jour_du_mois mois jour_de_semaine" + echo -e " ${YELLOW}Exemples :${RESET}" + echo -e " 0 3 * * * -> tous les jours a 03h00" + echo -e " 30 1 * * 0 -> tous les dimanches a 01h30" + echo -e " 0 4 */2 * * -> tous les 2 jours a 04h00" + echo "" + while true; do + read -rp "$(echo -e "${BOLD} Expression cron : ${RESET}")" cron_expr + # Validation basique : 5 champs + local field_count; field_count=$(echo "${cron_expr}" | wc -w) + [[ "${field_count}" -eq 5 ]] && break + echo -e "${RED} Expression invalide (5 champs requis).${RESET}" + done + ;; + esac + + # ── Option : notification par email ─────────────────────────────────────── + local mail_opt="" + echo "" + read -rp "$(echo -e "${YELLOW} Envoyer les logs cron par email ? (laisser vide pour ignorer) : ${RESET}")" mail_addr + if [[ -n "${mail_addr}" ]]; then + mail_opt="MAILTO=${mail_addr}" + else + mail_opt="MAILTO=\"\"" + fi + + # ── Affichage de la ligne cron finale ────────────────────────────────────── + local cron_line="${cron_expr} ${script_path} backup ${cron_tag}" + echo "" + hr + echo -e " ${BOLD}Entree cron qui sera ajoutee :${RESET}" + echo "" + [[ "${mail_opt}" != 'MAILTO=""' ]] && echo -e " ${YELLOW}${mail_opt}${RESET}" + echo -e " ${YELLOW}${cron_line}${RESET}" + echo "" + + confirm " Confirmer l'installation de cette tache cron ?" || { + log INFO "Planification annulee." + return 0 + } + + # ── Installation ─────────────────────────────────────────────────────────── + # Supprimer anciennes entrées redmine-backup (MAILTO + ligne cron), puis ajouter + local clean_cron + clean_cron=$(crontab -u "${cron_user}" -l 2>/dev/null \ + | grep -v "${cron_tag}" \ + | grep -v "^MAILTO=.*${cron_tag}" \ + || true) + + { + [[ -n "${clean_cron}" ]] && echo "${clean_cron}" + # MAILTO sur sa propre ligne, sans commentaire (cron l'interdit) + echo "${mail_opt}" + echo "${cron_line}" + } | crontab -u "${cron_user}" - + + log SUCCESS "Tache cron installée pour root" + log INFO "Vérification : crontab -u root -l | grep redmine" + + # ── Vérification s'assurer que cron tourne ──────────────────────────────── + if ! systemctl is-active --quiet cron 2>/dev/null && \ + ! systemctl is-active --quiet crond 2>/dev/null; then + log WARN "Le service cron ne semble pas actif. Démarrer avec : systemctl start cron" + else + log INFO "Service cron actif" + fi + + hr + echo -e "${GREEN}${BOLD} Planification cron configuree avec succes !${RESET}" + hr +} + +# ============================================================================= +# MENU INTERACTIF (si lancé sans argument) +# ============================================================================= + +interactive_menu() { + while true; do + clear + hr + echo -e "${BOLD}${CYAN}" + echo " ██████╗ ███████╗██████╗ ███╗ ███╗██╗███╗ ██╗███████╗" + echo " ██╔══██╗██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔════╝" + echo " ██████╔╝█████╗ ██║ ██║██╔████╔██║██║██╔██╗ ██║█████╗ " + echo " ██╔══██╗██╔══╝ ██║ ██║██║╚██╔╝██║██║██║╚██╗██║██╔══╝ " + echo " ██║ ██║███████╗██████╔╝██║ ╚═╝ ██║██║██║ ╚████║███████╗" + echo " ╚═╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝" + echo -e "${RESET}${BOLD} Gestionnaire de Sauvegarde Redmine${RESET}" + hr + echo -e " ${BOLD}Environnement :${RESET} ${RAILS_ENV} ${BOLD}Cible :${RESET} ${REDMINE_ROOT}" + hr + echo "" + echo -e " ${CYAN}[1]${RESET} ${BOLD}Creer une sauvegarde${RESET} - Sauvegarde complete (DB + fichiers + config)" + echo -e " ${CYAN}[2]${RESET} ${BOLD}Restaurer${RESET} - Menu interactif de restauration" + echo -e " ${CYAN}[3]${RESET} ${BOLD}Lister les sauvegardes${RESET} - Voir les archives disponibles" + echo -e " ${CYAN}[4]${RESET} ${BOLD}Planification Cron${RESET} - Gerer les sauvegardes automatiques" + echo -e " ${CYAN}[5]${RESET} ${BOLD}Afficher l'aide${RESET} - Documentation complete" + echo -e " ${CYAN}[0]${RESET} ${BOLD}Quitter${RESET}" + echo "" + hr + read -rp "$(echo -e "${BOLD} Votre choix : ${RESET}")" choice + echo "" + case "${choice}" in + 1) do_backup ;; + 2) do_restore ;; + 3) do_list ;; + 4) do_cron ;; + 5) do_help ;; + 0) echo -e "${GREEN} Au revoir !${RESET}\n"; exit 0 ;; + *) echo -e "${RED} Choix invalide.${RESET}" ; sleep 1; continue ;; + esac + # Pause courte puis retour automatique au menu + echo "" + echo -e "${BLUE} ↩ Retour au menu dans 3 secondes...${RESET}" + sleep 3 + done +} + +# ============================================================================= +# POINT D'ENTRÉE +# ============================================================================= + +main() { + require_root + require_command tar + require_command gzip + load_env + + local cmd="${1:-menu}" + case "${cmd}" in + backup) do_backup ;; + restore) do_restore ;; + list) do_list ;; + cron) do_cron ;; + help|-h|--help) do_help ;; + menu|"") interactive_menu ;; + *) echo -e "${RED}Commande inconnue : ${cmd}${RESET}"; do_help; exit 1 ;; + esac +} + +main "$@" \ No newline at end of file