#!/bin/bash # Géré par Ansible (rôle sauvegardes). Test de restauration du portail # (étape 13, puis chaque semaine) : # 1. télécharge la dernière sauvegarde quotidienne de Dedibackup ; # 2. la déchiffre (clé age du serveur) et en extrait la base et le .env ; # 3. restaure la base dans un conteneur MariaDB jetable, isolé ; # 4. fait tourner le code du portail contre cette copie (migrations, # comptages), avec l'APP_KEY restaurée ; # 5. compare les tables à la production et publie une métrique Prometheus. # Ne modifie jamais la production. N'affiche aucun secret. set -euo pipefail set +x source /root/.dedibackup CLE="{{ sauvegardes_age_cle }}" APP="/var/www/portail" CONTENEUR="syllabus-restauration" DOSSIER=$(mktemp -d /var/tmp/restauration-XXXXXX) nettoyer() { docker rm -f "$CONTENEUR" >/dev/null 2>&1 || true rm -rf "$DOSSIER" } trap nettoyer EXIT echo "== 1. Dernière sauvegarde quotidienne" DERNIERE=$(lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" \ -e "set ftp:ssl-allow no; cls -1 quotidienne-*.tar.zst.age; quit" | sort | tail -1) [ -n "$DERNIERE" ] || { echo "aucune sauvegarde trouvée"; exit 1; } lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" \ -e "set ftp:ssl-allow no; get $DERNIERE -o $DOSSIER/archive.age; quit" echo "$DERNIERE ($(du -h "$DOSSIER/archive.age" | cut -f1))" echo "== 2. Déchiffrement et extraction" mkdir "$DOSSIER/extrait" age -d -i "$CLE" "$DOSSIER/archive.age" \ | tar -I zstd -xf - -C "$DOSSIER/extrait" portail.sql var/www/portail/.env [ -s "$DOSSIER/extrait/portail.sql" ] || { echo "portail.sql absent de l'archive"; exit 1; } APP_KEY_RESTAUREE=$(grep -oP '^APP_KEY=\K.*' "$DOSSIER/extrait/var/www/portail/.env") if [ "$APP_KEY_RESTAUREE" = "$(grep -oP '^APP_KEY=\K.*' "$APP/.env")" ]; then echo "APP_KEY restaurée identique à la production (valeur non affichée)" else echo "APP_KEY restaurée DIFFÉRENTE de la production"; exit 1 fi echo "== 3. Restauration dans un conteneur jetable" MDP=$(openssl rand -hex 16) # Réseau du portail (Docker tourne sans pont par défaut), aucun port publié : # seul l'hôte joint ce conteneur, par son adresse interne. docker run -d --name "$CONTENEUR" --network portail_default \ -e MARIADB_ROOT_PASSWORD="$MDP" {{ sauvegardes_image_mariadb }} >/dev/null for _ in $(seq 1 60); do docker exec "$CONTENEUR" sh -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" mariadb -uroot -e "SELECT 1"' >/dev/null 2>&1 && break sleep 1 done docker exec -i "$CONTENEUR" sh -c 'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb -uroot' < "$DOSSIER/extrait/portail.sql" IP=$(docker inspect -f '{% raw %}{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}{% endraw %}' "$CONTENEUR") echo "base restaurée dans $CONTENEUR" echo "== 4. Le portail tourne contre la copie" # APP_CONFIG_CACHE vers un fichier inexistant : Laravel ignore la config mise # en cache (qui pointe vers la production) et lit ces variables en priorité. artisan_copie() { env APP_CONFIG_CACHE="$DOSSIER/config-absente.php" APP_KEY="$APP_KEY_RESTAUREE" \ DB_HOST="$IP" DB_PORT=3306 DB_USERNAME=root DB_PASSWORD="$MDP" DB_DATABASE={{ sauvegardes_portail_base }} \ CACHE_STORE=array SESSION_DRIVER=array QUEUE_CONNECTION=sync LOG_CHANNEL=stderr \ php{{ sauvegardes_php_version }} "$APP/artisan" "$@" } # Échec de connexion = échec du test (set -e), pas un « 0 en attente » trompeur. STATUT=$(artisan_copie migrate:status) APPLIQUEES=$(grep -c " Ran" <<<"$STATUT" || true) EN_ATTENTE=$(grep -c "Pending" <<<"$STATUT" || true) echo "migrations dans la copie : $APPLIQUEES appliquées, $EN_ATTENTE en attente" [ "$APPLIQUEES" -gt 0 ] && [ "$EN_ATTENTE" = "0" ] || exit 1 echo "== 5. Comparaison avec la production (lignes par table)" compter() { docker exec "$1" sh -c 'export MYSQL_PWD="$MARIADB_ROOT_PASSWORD" for t in $(mariadb -uroot -N -e "SHOW TABLES" {{ sauvegardes_portail_base }}); do echo "$t $(mariadb -uroot -N -e "SELECT COUNT(*) FROM \`$t\`" {{ sauvegardes_portail_base }})" done' } compter "$CONTENEUR" | sort > "$DOSSIER/copie.txt" compter {{ sauvegardes_portail_conteneur }} | sort > "$DOSSIER/production.txt" printf "%-28s %10s %10s\n" table copie production join -a1 -a2 -e "absente" -o 0,1.2,2.2 "$DOSSIER/copie.txt" "$DOSSIER/production.txt" | while read -r t c p; do printf "%-28s %10s %10s%s\n" "$t" "$c" "$p" "$([ "$c" = "$p" ] || echo ' ≠ (écart depuis la sauvegarde ?)')" done TABLES_MANQUANTES=$(join -v2 "$DOSSIER/copie.txt" "$DOSSIER/production.txt" | wc -l) [ "$TABLES_MANQUANTES" = "0" ] || { echo "$TABLES_MANQUANTES table(s) de production absente(s) de la copie"; exit 1; } echo "lab_sessions vues par le portail dans la copie : $(artisan_copie tinker --execute='echo App\Models\LabSession::count();' 2>/dev/null | tail -1)" DOSSIER_TEXTFILE="{{ sauvegardes_dossier_textfile | default('/var/lib/node_exporter/textfile') }}" cat > "$DOSSIER_TEXTFILE/restauration.prom.tmp" <