#!/usr/bin/env bash set -euo pipefail umask 077 ############################################################################### # backup-bdd-recette.sh # # Ce script réalise une sauvegarde logique de plusieurs bases PostgreSQL # définies dans le fichier .env, exporte également la liste des rôles/users, # puis transfère l’ensemble vers une machine distante de stockage. # # Fonctionnement global : # 1. charge la configuration depuis le fichier .env ; # 2. vérifie les dépendances nécessaires ; # 3. prépare les chemins, logs et variables de connexion ; # 4. empêche l’exécution simultanée grâce à un verrou ; # 5. crée les dossiers de destination sur la machine distante ; # 6. exporte les rôles PostgreSQL ; # 7. dump chaque base au format personnalisé PostgreSQL ; # 8. transfère chaque fichier vers le serveur distant ; # 9. applique une rotation distante sur 10 jours ; # 10. envoie un bilan sur Discord : # - 1 message global si tout est OK ; # - en cas d’erreur partielle : # * USERS OK -> message simple ; # * USERS KO -> message détaillé ; # * DB OK -> message simple ; # * DB KO -> message détaillé. ############################################################################### ####################################### # Chargement du .env ####################################### 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 requises ####################################### : "${ENV_NAME:?Variable ENV_NAME manquante}" : "${PGHOST:?Variable PGHOST manquante}" : "${PGPORT:?Variable PGPORT manquante}" : "${PGUSER:?Variable PGUSER manquante}" : "${PGPASSWORD:?Variable PGPASSWORD manquante}" : "${DBS:?Variable DBS manquante}" : "${BACKUP_REMOTE_USER:?Variable BACKUP_REMOTE_USER manquante}" : "${BACKUP_REMOTE_HOST:?Variable BACKUP_REMOTE_HOST manquante}" : "${BACKUP_REMOTE_DIR:?Variable BACKUP_REMOTE_DIR manquante}" : "${SSH_KEY:?Variable SSH_KEY manquante}" : "${SSH_TIMEOUT:?Variable SSH_TIMEOUT manquante}" : "${BACKUP_LOG_DIR:?Variable BACKUP_LOG_DIR manquante}" ####################################### # Configuration principale ####################################### read -r -a DBS_ARRAY <<< "$DBS" validate_db_name() { local db_name="$1" [[ -n "$db_name" ]] || { echo "ERROR: nom de base vide dans DBS" >&2 exit 1 } [[ "$db_name" =~ ^[a-zA-Z0-9_]+$ ]] || { echo "ERROR: nom de base invalide dans DBS : $db_name" >&2 exit 1 } } for DB in "${DBS_ARRAY[@]}"; do validate_db_name "$DB" done IA_SSH="${BACKUP_REMOTE_USER}@${BACKUP_REMOTE_HOST}" RETENTION_DAYS=10 BACKUP_REMOTE_SSH_PORT="${BACKUP_REMOTE_SSH_PORT:-22}" BACKUP_KNOWN_HOSTS_STRICT="${BACKUP_KNOWN_HOSTS_STRICT:-yes}" BACKUP_KNOWN_HOSTS_FILE="${BACKUP_KNOWN_HOSTS_FILE:-${HOME}/.ssh/known_hosts}" [[ "$BACKUP_REMOTE_SSH_PORT" =~ ^[0-9]+$ ]] || { echo "ERROR: BACKUP_REMOTE_SSH_PORT invalide" >&2 exit 1 } [[ "$PGPORT" =~ ^[0-9]+$ ]] || { echo "ERROR: PGPORT invalide" >&2 exit 1 } [[ "$SSH_TIMEOUT" =~ ^[0-9]+$ ]] || { echo "ERROR: SSH_TIMEOUT invalide" >&2 exit 1 } [[ "$PGUSER" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]] || { echo "ERROR: PGUSER invalide" >&2 exit 1 } case "${BACKUP_KNOWN_HOSTS_STRICT,,}" in yes|y|oui|o|true|1) BACKUP_KNOWN_HOSTS_STRICT="yes" ;; no|n|non|false|0) BACKUP_KNOWN_HOSTS_STRICT="no" ;; *) echo "ERROR: BACKUP_KNOWN_HOSTS_STRICT invalide" >&2 exit 1 ;; esac mkdir -p "$(dirname "$BACKUP_KNOWN_HOSTS_FILE")" chmod 700 "$(dirname "$BACKUP_KNOWN_HOSTS_FILE")" || true touch "$BACKUP_KNOWN_HOSTS_FILE" chmod 600 "$BACKUP_KNOWN_HOSTS_FILE" || true SSH_OPTS=( -i "$SSH_KEY" -p "$BACKUP_REMOTE_SSH_PORT" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout="${SSH_TIMEOUT}" -o StrictHostKeyChecking="${BACKUP_KNOWN_HOSTS_STRICT}" -o UserKnownHostsFile="${BACKUP_KNOWN_HOSTS_FILE}" ) SCP_OPTS=( -i "$SSH_KEY" -P "$BACKUP_REMOTE_SSH_PORT" -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout="${SSH_TIMEOUT}" -o StrictHostKeyChecking="${BACKUP_KNOWN_HOSTS_STRICT}" -o UserKnownHostsFile="${BACKUP_KNOWN_HOSTS_FILE}" ) LOG_DIR="${BACKUP_LOG_DIR}" mkdir -p "$LOG_DIR" TS="$(date +'%Y-%m-%d_%H-%M-%S')" BACKUP_DIR_NAME="backup_${TS}" LOG_FILE="${LOG_DIR}/${BACKUP_DIR_NAME}.log" TMP_DIR="$(mktemp -d /tmp/pg_dump_XXXXXX)" || { echo "ERROR: impossible de créer le dossier temporaire" >&2 exit 1 } exec > >(tee -a "$LOG_FILE") 2>&1 log() { echo "---- $(date +'%Y-%m-%d %H:%M:%S') ---- $*"; } require_cmd() { command -v "$1" >/dev/null 2>&1 } safe_remove_dir() { local dir="${1:-}" [[ -n "$dir" ]] || return 0 [[ "$dir" == /tmp/pg_dump_* ]] || { log "WARNING: suppression refusée pour le chemin inattendu : $dir" return 1 } rm -rf -- "$dir" } export PGPASSWORD ####################################### # Vérification dépendances minimales ####################################### for cmd in ssh scp curl jq pg_dump pg_dumpall mktemp; do require_cmd "$cmd" || { echo "ERROR: commande manquante : $cmd" >&2 exit 1 } done [[ -f "$SSH_KEY" ]] || { echo "ERROR: clé SSH introuvable : $SSH_KEY" >&2 exit 1 } [[ -r "$SSH_KEY" ]] || { echo "ERROR: clé SSH non lisible : $SSH_KEY" >&2 exit 1 } [[ ! -L "$SSH_KEY" ]] || { echo "ERROR: la clé SSH ne doit pas être un lien symbolique : $SSH_KEY" >&2 exit 1 } chmod 600 "$SSH_KEY" || true ####################################### # Configuration Discord ####################################### DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" DISCORD_PING="${DISCORD_PING:-@here}" discord_send() { local msg="$1" [[ -z "${DISCORD_WEBHOOK_URL:-}" ]] && return 0 local payload payload="$(jq -n --arg content "$msg" '{content: $content}')" || { log "ERROR: impossible de construire le payload JSON Discord" return 1 } curl -fsS \ -H "Content-Type: application/json" \ -d "$payload" \ "$DISCORD_WEBHOOK_URL" >/dev/null || true } ####################################### # Message global OK ####################################### discord_msg_global_ok() { local msg msg="$(cat </dev/null; then log "ERROR: Backup déjà en cours" discord_msg_users_error "" "" "Lock already exists" exit 1 fi cleanup() { rm -rf -- "$LOCK_DIR" safe_remove_dir "$TMP_DIR" || true } trap cleanup EXIT ####################################### # Préparation du dossier distant ####################################### log "Creating remote directories" MKDIR_CMD="mkdir -p '${BACKUP_REMOTE_DIR}/user'" for DB in "${DBS_ARRAY[@]}"; do MKDIR_CMD+=" '${BACKUP_REMOTE_DIR}/${DB}'" done if ! ssh "${SSH_OPTS[@]}" "$IA_SSH" "$MKDIR_CMD"; then log "ERROR: remote mkdir failed" discord_msg_users_error "" "" "Remote mkdir failed" exit 1 fi ####################################### # Export des rôles PostgreSQL ####################################### ROLES_FILE="${TMP_DIR}/user_${TS}.sql" set +e log "Export des rôles PostgreSQL" pg_dumpall \ -h "$PGHOST" \ -p "$PGPORT" \ -U "$PGUSER" \ --globals-only \ > "$ROLES_FILE" RET=$? if [[ $RET -ne 0 ]]; then USERS_OK= USERS_EXPORT_OK= USERS_DETAILS="roles export failed" else log "Export des rôles OK : $ROLES_FILE" fi if [[ -n "${USERS_EXPORT_OK:-}" ]]; then scp "${SCP_OPTS[@]}" "$ROLES_FILE" "$IA_SSH:${BACKUP_REMOTE_DIR}/user/" RET=$? if [[ $RET -ne 0 ]]; then USERS_OK= USERS_TRANSFER_OK= if [[ -n "$USERS_DETAILS" ]]; then USERS_DETAILS+=" | roles transfer failed" else USERS_DETAILS="roles transfer failed" fi else log "Transfert des rôles OK" fi fi set -e ####################################### # Dump des bases ####################################### set +e for DB in "${DBS_ARRAY[@]}"; do FILE="${TMP_DIR}/${DB}_${TS}.dump" DB_DUMP_OK["$DB"]=true DB_TRANSFER_OK["$DB"]=true DB_DETAILS["$DB"]="" log "Dump $DB" pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -Fc -d "$DB" -f "$FILE" RET=$? if [[ $RET -ne 0 ]]; then DUMPS_OK= DB_DUMP_OK["$DB"]= DB_TRANSFER_OK["$DB"]= DB_DETAILS["$DB"]="dump failed" continue fi scp "${SCP_OPTS[@]}" "$FILE" "$IA_SSH:${BACKUP_REMOTE_DIR}/${DB}/" RET=$? if [[ $RET -ne 0 ]]; then DUMPS_OK= DB_TRANSFER_OK["$DB"]= DB_DETAILS["$DB"]="transfer failed" fi done set -e ####################################### # Rotation distante ####################################### log "Starting remote rotation: delete backups older than ${RETENTION_DAYS} days" set +e ssh "${SSH_OPTS[@]}" "$IA_SSH" "find '${BACKUP_REMOTE_DIR}/user' -type f -name 'user_*.sql' -mtime +${RETENTION_DAYS} -delete" RET=$? if [[ $RET -ne 0 ]]; then log "ERROR: remote rotation failed for users" else log "Remote rotation OK for users" fi for DB in "${DBS_ARRAY[@]}"; do ssh "${SSH_OPTS[@]}" "$IA_SSH" "find '${BACKUP_REMOTE_DIR}/${DB}' -type f -name '${DB}_*.dump' -mtime +${RETENTION_DAYS} -delete" RET=$? if [[ $RET -ne 0 ]]; then log "ERROR: remote rotation failed for ${DB}" else log "Remote rotation OK for ${DB}" fi done set -e log "Remote rotation finished" ####################################### # Nettoyage local ####################################### safe_remove_dir "$TMP_DIR" || true ####################################### # Bilan final Discord ####################################### MODE_KO= [[ -z "${DUMPS_OK:-}" ]] && MODE_KO=true [[ -z "${USERS_OK:-}" ]] && MODE_KO=true if [[ -z "${MODE_KO:-}" ]]; then discord_msg_global_ok exit 0 fi if [[ -n "${USERS_EXPORT_OK:-}" && -n "${USERS_TRANSFER_OK:-}" ]]; then discord_msg_users_ok_simple else discord_msg_users_error "${USERS_EXPORT_OK:+true}" "${USERS_TRANSFER_OK:+true}" "$USERS_DETAILS" fi for DB in "${DBS_ARRAY[@]}"; do if [[ -n "${DB_DUMP_OK[$DB]:-}" && -n "${DB_TRANSFER_OK[$DB]:-}" ]]; then discord_msg_db_ok_simple "$DB" else discord_msg_db_error "$DB" "${DB_DUMP_OK[$DB]:+true}" "${DB_TRANSFER_OK[$DB]:+true}" "${DB_DETAILS[$DB]}" fi done exit 2