#!/usr/bin/env bash set -euo pipefail ############################################################################### # restore-postgres-db.sh # # Finalité : # Restaurer une base PostgreSQL à partir du dernier dump disponible sur un # serveur distant. # # Fonctionnement global : # 1. charge les variables depuis un fichier .env placé à côté du script ; # 2. vérifie les dépendances locales nécessaires ; # 3. détermine la base à restaurer ; # 4. teste la connexion SSH au serveur distant ; # 5. recherche automatiquement le dump .dump le plus récent ; # 6. recherche automatiquement le fichier .sql des rôles le plus récent ; # 7. télécharge les fichiers dans un dossier temporaire local ; # 8. vérifie que le dump téléchargé est valide ; # 9. demande confirmation si la base existe déjà ; # 10. recrée la base si nécessaire ; # 11. restaure éventuellement les rôles PostgreSQL ; # 12. restaure le contenu de la base ; # 13. supprime les fichiers temporaires. # # Hypothèses : # - machine locale de restauration sous Linux Debian/Ubuntu ou macOS ; # - serveur distant compatible GNU find si l'on utilise -printf ; # - dumps PostgreSQL au format custom (.dump) ; # - rôles exportés dans un fichier .sql rejouable. ############################################################################### ############################################################################### # Chargement du .env # # Le script cherche automatiquement un fichier .env dans le même dossier # que lui. Cela évite d'avoir des chemins absolus codés en dur. ############################################################################### SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${SCRIPT_DIR}/.env" if [[ ! -f "$ENV_FILE" ]]; then echo "ERROR: fichier .env introuvable : $ENV_FILE" >&2 exit 1 fi set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a ############################################################################### # Vérification des variables obligatoires # # Ces variables doivent exister dans le .env. Sans elles, le script ne peut # pas fonctionner correctement. ############################################################################### : "${PGHOST:?Variable PGHOST manquante}" : "${PGPORT:?Variable PGPORT manquante}" : "${PGUSER:?Variable PGUSER manquante}" : "${PGPASSWORD:?Variable PGPASSWORD manquante}" : "${BACKUP_LOG_DIR:?Variable BACKUP_LOG_DIR manquante}" : "${SSH_KEY:?Variable SSH_KEY manquante}" : "${IA_SSH:?Variable IA_SSH manquante}" : "${IA_BASE_DIR:?Variable IA_BASE_DIR manquante}" ############################################################################### # Variables optionnelles # # Valeurs par défaut appliquées si elles ne sont pas définies dans le .env. ############################################################################### SSH_TIMEOUT="${SSH_TIMEOUT:-10}" # Nom du dossier distant contenant les exports SQL des rôles PostgreSQL. # Exemple distant : # /home/backup/backups/user REMOTE_ROLES_DIR_NAME="${REMOTE_ROLES_DIR_NAME:-user}" ############################################################################### # Mise en place des logs # # Tous les messages stdout/stderr sont redirigés vers la console ET vers # un fichier de log horodaté. ############################################################################### mkdir -p "$BACKUP_LOG_DIR" LOG_FILE="${BACKUP_LOG_DIR}/restore_$(date +'%Y-%m-%d_%H-%M-%S').log" touch "$LOG_FILE" exec > >(tee -a "$LOG_FILE") 2>&1 ############################################################################### # Fonctions utilitaires ############################################################################### # Écrit un message daté dans les logs. log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" } # Affiche une erreur puis quitte le script. fail() { echo "ERROR: $*" >&2 exit 1 } # Vérifie que le nom de base est acceptable. # # Règle choisie ici : # - lettres # - chiffres # - underscore # # Cela évite beaucoup de problèmes d'injection ou de nom invalide. validate_db_name() { local db_name="$1" [[ -n "$db_name" ]] || return 1 [[ "$db_name" =~ ^[a-zA-Z0-9_]+$ ]] || return 1 return 0 } # Vérifie qu'une commande existe. require_command() { local cmd="$1" command -v "$cmd" >/dev/null 2>&1 } # Petite fonction de confirmation utilisateur. # Retourne 0 si oui, 1 sinon. confirm_yes_no() { local prompt="$1" local answer="" read -r -p "$prompt" answer case "${answer,,}" in oui|o|yes|y) return 0 ;; *) return 1 ;; esac } ############################################################################### # Installation des dépendances manquantes # # On privilégie ici les clients PostgreSQL plutôt que l'installation complète # du serveur si ce n'est pas nécessaire. ############################################################################### install_postgres_client() { log "Installation des outils PostgreSQL..." if require_command apt-get; then sudo apt-get update sudo apt-get install -y postgresql-client elif require_command brew; then brew install postgresql else fail "impossible d'installer automatiquement les outils PostgreSQL : gestionnaire non supporté" fi } install_scp_client() { log "Installation de OpenSSH client..." if require_command apt-get; then sudo apt-get update sudo apt-get install -y openssh-client elif require_command brew; then fail "scp / ssh introuvable sur macOS. Installe OpenSSH manuellement." else fail "impossible d'installer automatiquement openssh-client : gestionnaire non supporté" fi } ############################################################################### # Résolution du nom de la base à restaurer # # Priorité : # 1. variable DB si elle existe déjà # 2. sélection depuis DBS si défini dans le .env # 3. saisie manuelle # # IMPORTANT : # - les messages d'interface sont envoyés sur stderr ; # - seul le résultat final (nom de base) est envoyé sur stdout. # # Cela évite de polluer la variable récupérée via : # DB="$(resolve_db_name)" ############################################################################### resolve_db_name() { local selected_db="" local choice="" local custom_db="" local -a dbs_array=() if [[ -n "${DB:-}" ]]; then printf '%s\n' "$DB" return 0 fi if [[ -n "${DBS:-}" ]]; then read -r -a dbs_array <<< "$DBS" if [[ "${#dbs_array[@]}" -gt 0 ]]; then echo "Bases disponibles dans le .env :" >&2 for i in "${!dbs_array[@]}"; do printf ' %d) %s\n' "$((i + 1))" "${dbs_array[$i]}" >&2 done echo >&2 read -r -p "Voulez-vous utiliser une base de cette liste ? (oui/non) : " choice case "${choice,,}" in oui|o|yes|y) while true; do read -r -p "Sélectionnez le numéro de la base à restaurer : " selected_db if [[ "$selected_db" =~ ^[0-9]+$ ]] && (( selected_db >= 1 && selected_db <= ${#dbs_array[@]} )); then printf '%s\n' "${dbs_array[$((selected_db - 1))]}" return 0 fi echo "Choix invalide. Veuillez entrer un numéro entre 1 et ${#dbs_array[@]}." >&2 done ;; non|n|no) read -r -p "Entrez le nom de la base à restaurer hors liste : " custom_db printf '%s\n' "$custom_db" return 0 ;; *) echo "Réponse invalide. Saisie manuelle de la base." >&2 read -r -p "Entrez le nom de la base à restaurer : " custom_db printf '%s\n' "$custom_db" return 0 ;; esac fi fi read -r -p "Aucune base définie via DB ou DBS. Entrez le nom de la base à restaurer : " custom_db printf '%s\n' "$custom_db" } ############################################################################### # Détermination de la base cible ############################################################################### DB="$(resolve_db_name)" if ! validate_db_name "$DB"; then fail "nom de base invalide : '$DB'. Caractères autorisés : lettres, chiffres, underscore." fi log "Base cible sélectionnée : $DB" ############################################################################### # Vérification des dépendances locales ############################################################################### if ! require_command psql; then log "psql introuvable." install_postgres_client fi if ! require_command pg_restore; then log "pg_restore introuvable." install_postgres_client fi if ! require_command createdb; then log "createdb introuvable." install_postgres_client fi if ! require_command dropdb; then log "dropdb introuvable." install_postgres_client fi if ! require_command ssh; then log "ssh introuvable." install_scp_client fi if ! require_command scp; then log "scp introuvable." install_scp_client fi ############################################################################### # Vérification de la clé SSH ############################################################################### [[ -f "$SSH_KEY" ]] || fail "clé SSH introuvable : $SSH_KEY" ############################################################################### # Configuration SSH # # BatchMode=yes : # empêche les demandes interactives de mot de passe. # # IdentitiesOnly=yes : # force l'usage de la clé fournie. ############################################################################### SSH_OPTS=( -i "$SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout="$SSH_TIMEOUT" ) ############################################################################### # Test de connexion SSH # # On teste la connexion immédiatement pour échouer tôt si le serveur distant # n'est pas joignable ou si la clé n'est pas acceptée. ############################################################################### log "Test de connexion SSH vers ${IA_SSH}..." ssh "${SSH_OPTS[@]}" "$IA_SSH" "echo OK" >/dev/null 2>&1 \ || fail "connexion SSH impossible vers ${IA_SSH}" ############################################################################### # Définition des chemins distants # # Structure attendue : # $IA_BASE_DIR/$DB/ -> contient les dumps .dump de la base # $IA_BASE_DIR/$REMOTE_ROLES_DIR_NAME/ -> contient les exports .sql des rôles ############################################################################### REMOTE_DUMP_DIR="${IA_BASE_DIR}/${DB}" REMOTE_ROLES_DIR="${IA_BASE_DIR}/${REMOTE_ROLES_DIR_NAME}" log "Recherche du dernier dump distant pour ${DB} dans : $REMOTE_DUMP_DIR" log "Recherche du dernier fichier de rôles dans : $REMOTE_ROLES_DIR" ############################################################################### # Recherche automatique du dump le plus récent # # On cherche tous les fichiers .dump dans le dossier distant de la base, # puis on les trie par date de modification décroissante. # # Le premier résultat est donc le plus récent. ############################################################################### REMOTE_DUMP_PATH="$( ssh "${SSH_OPTS[@]}" "$IA_SSH" " if [ -d '$REMOTE_DUMP_DIR' ]; then find '$REMOTE_DUMP_DIR' -maxdepth 1 -type f -name '*.dump' -printf '%T@ %p\n' 2>/dev/null \ | sort -nr \ | head -n 1 \ | cut -d' ' -f2- fi " )" if [[ -z "$REMOTE_DUMP_PATH" ]]; then fail "aucun dump distant trouvé dans : $REMOTE_DUMP_DIR" fi REMOTE_DUMP_FILE="$(basename "$REMOTE_DUMP_PATH")" ############################################################################### # Recherche automatique du dernier fichier des rôles # # Ce fichier n'est pas obligatoire pour restaurer la base elle-même. # Si aucun fichier de rôles n'est trouvé, le script continue quand même. ############################################################################### REMOTE_ROLES_PATH="$( ssh "${SSH_OPTS[@]}" "$IA_SSH" " if [ -d '$REMOTE_ROLES_DIR' ]; then find '$REMOTE_ROLES_DIR' -maxdepth 1 -type f -name '*.sql' -printf '%T@ %p\n' 2>/dev/null \ | sort -nr \ | head -n 1 \ | cut -d' ' -f2- fi " )" REMOTE_ROLES_FILE="" if [[ -n "$REMOTE_ROLES_PATH" ]]; then REMOTE_ROLES_FILE="$(basename "$REMOTE_ROLES_PATH")" fi log "Dernier dump distant sélectionné : $REMOTE_DUMP_PATH" if [[ -n "$REMOTE_ROLES_PATH" ]]; then log "Dernier fichier des rôles sélectionné : $REMOTE_ROLES_PATH" else log "Aucun fichier des rôles trouvé sur le serveur distant." fi ############################################################################### # Dossier temporaire local # # Les fichiers téléchargés sont stockés dans un dossier temporaire, puis # supprimés automatiquement à la fin du script. ############################################################################### TMP_DIR="$(mktemp -d)" LOCAL_DUMP_PATH="${TMP_DIR}/${REMOTE_DUMP_FILE}" LOCAL_ROLES_PATH="" cleanup() { rm -rf "$TMP_DIR" } trap cleanup EXIT ############################################################################### # Téléchargement du dump et des rôles ############################################################################### log "Téléchargement du dump..." scp "${SSH_OPTS[@]}" "${IA_SSH}:${REMOTE_DUMP_PATH}" "$LOCAL_DUMP_PATH" if [[ ! -f "$LOCAL_DUMP_PATH" ]]; then fail "échec du téléchargement du dump : fichier local introuvable" fi if [[ -n "$REMOTE_ROLES_PATH" ]]; then LOCAL_ROLES_PATH="${TMP_DIR}/${REMOTE_ROLES_FILE}" log "Téléchargement du fichier des rôles..." scp "${SSH_OPTS[@]}" "${IA_SSH}:${REMOTE_ROLES_PATH}" "$LOCAL_ROLES_PATH" if [[ ! -f "$LOCAL_ROLES_PATH" ]]; then fail "échec du téléchargement du fichier des rôles" fi else log "La restauration des rôles sera ignorée." fi ############################################################################### # Vérification du format du dump # # pg_restore --list permet de vérifier que le fichier est bien un dump # PostgreSQL lisible au format attendu. ############################################################################### if ! pg_restore --list "$LOCAL_DUMP_PATH" >/dev/null 2>&1; then fail "le fichier téléchargé n'est pas un dump PostgreSQL valide : $LOCAL_DUMP_PATH" fi ############################################################################### # Vérification de l'existence de la base # # Si la base existe déjà, on demande explicitement à l'utilisateur s'il veut # l'écraser. Sinon, on la crée simplement. ############################################################################### DB_EXISTS="false" if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -tAc \ "SELECT 1 FROM pg_database WHERE datname = '$DB'" | grep -q '^1$'; then DB_EXISTS="true" fi if [[ "$DB_EXISTS" == "true" ]]; then if confirm_yes_no "La base '${DB}' existe déjà. Voulez-vous l'écraser ? (oui/non) : "; then log "Suppression de la base existante : $DB" # Coupe les connexions actives sur la base pour permettre sa suppression. psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -c \ "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB' AND pid <> pg_backend_pid();" >/dev/null dropdb -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" "$DB" createdb -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" "$DB" else log "Restauration annulée par l'utilisateur." exit 0 fi else log "Création de la base : $DB" createdb -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" "$DB" fi ############################################################################### # Restauration éventuelle des rôles PostgreSQL # # Cette étape est optionnelle. Elle permet par exemple de recréer des rôles # ou utilisateurs nécessaires avant la restauration de la base. # # Attention : # le fichier doit être conçu pour être rejouable sans casser l'existant. ############################################################################### if [[ -n "$LOCAL_ROLES_PATH" && -f "$LOCAL_ROLES_PATH" ]]; then log "Restauration des rôles PostgreSQL..." psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -f "$LOCAL_ROLES_PATH" else log "Aucune restauration des rôles effectuée." fi ############################################################################### # Restauration de la base # # --clean / --if-exists : # demande à pg_restore de supprimer les objets avant de les recréer. # # --no-owner : # évite les problèmes si le propriétaire original n'existe pas localement. # # --verbose : # détaille la restauration dans les logs. ############################################################################### log "Restauration de la base '${DB}' depuis ${LOCAL_DUMP_PATH}..." pg_restore \ -h "$PGHOST" \ -p "$PGPORT" \ -U "$PGUSER" \ -d "$DB" \ --clean \ --if-exists \ --no-owner \ --verbose \ "$LOCAL_DUMP_PATH" log "Restauration terminée avec succès pour la base : $DB"