#!/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 10 secondes...${RESET}" sleep 10 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 "$@"