Paillasse/ansible/roles/sauvegardes/templates/syllabus-test-restauration.sh.j2
Adam Belkacemi 3f0b565e1e sauvegarde la base du portail et teste sa restauration chaque semaine
La sauvegarde ne couvrait que /etc, /home et /root : la base MariaDB du
portail et son .env (APP_KEY) en étaient absents. Ajoute aussi le réglage
du quota et du gabarit du projet des labs dans Ansible, la reprise des VM
au démarrage de l'hôte (Nova), et le script du test de capacité.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:28:50 +02:00

101 lines
5.3 KiB
Django/Jinja

#!/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" <<METRIQUE
# HELP syllabus_derniere_restauration_reussie_timestamp Horodatage Unix du dernier test de restauration du portail réussi.
# TYPE syllabus_derniere_restauration_reussie_timestamp gauge
syllabus_derniere_restauration_reussie_timestamp $(date +%s)
METRIQUE
mv "$DOSSIER_TEXTFILE/restauration.prom.tmp" "$DOSSIER_TEXTFILE/restauration.prom"
logger -t syllabus-test-restauration "Restauration de $DERNIERE réussie"
echo "== RESTAURATION RÉUSSIE ($DERNIERE)"