Files
redmine/backup.sh_redmine.sh

846 lines
38 KiB
Bash

#!/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 "$@"