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>
This commit is contained in:
Adam Belkacemi 2026-10-09 17:28:50 +02:00
parent 424d0f6f79
commit 3f0b565e1e
11 changed files with 458 additions and 1 deletions

View file

@ -111,6 +111,31 @@
group: root
mode: "0600"
- name: créer le dossier des surcharges nova
ansible.builtin.file:
path: "{{ kolla_config_dir }}/config/nova"
state: directory
owner: root
group: root
mode: "0750"
- name: déployer la surcharge de nova-compute (versionnée, sans secret)
ansible.builtin.copy:
src: "{{ playbook_dir }}/../kolla/config/nova/nova-compute.conf"
dest: "{{ kolla_config_dir }}/config/nova/nova-compute.conf"
owner: root
group: root
mode: "0640"
register: kolla_surcharge_nova
- name: rappeler de reconfigurer nova si la surcharge a changé
ansible.builtin.debug:
msg: >
Surcharge nova-compute modifiée : lancer
`kolla-ansible reconfigure -i {{ kolla_config_dir }}/all-in-one --tags nova`
sur le serveur (voir docs/runbooks/openstack.md).
when: kolla_surcharge_nova.changed
- name: installer les dépendances Ansible de Kolla (collections Galaxy)
ansible.builtin.command:
cmd: "{{ kolla_venv }}/bin/kolla-ansible install-deps"

View file

@ -0,0 +1,12 @@
---
# Projet des labs (créé à l'étape 6) : ce rôle garantit son gabarit et ses
# quotas. Le quota est un filet de sécurité ; la vraie limite de capacité est
# la réserve mémoire de Nova (kolla/config/nova/nova-compute.conf), voir
# docs/capacite.md.
openstack_projet: paillasse
openstack_projet_quota:
instances: 40
cores: 40
ram: 30720
openstack_gabarits:
- { nom: eleve.petit, ram: 768, vcpus: 1, disque: 5 }

View file

@ -0,0 +1,39 @@
---
# Client openstack de Kolla, compte admin (clouds.yaml généré par post-deploy).
- name: lire les gabarits existants
ansible.builtin.command: openstack flavor list -f json
environment: &os_admin
PATH: "/opt/kolla-venv/bin:{{ ansible_env.PATH }}"
OS_CLIENT_CONFIG_FILE: /etc/kolla/clouds.yaml
OS_CLOUD: kolla-admin
register: openstack_gabarits_existants
changed_when: false
- name: créer les gabarits manquants
ansible.builtin.command: >-
openstack flavor create --public --ram {{ item.ram }} --vcpus {{ item.vcpus }}
--disk {{ item.disque }} {{ item.nom }}
environment: *os_admin
loop: "{{ openstack_gabarits }}"
when: item.nom not in (openstack_gabarits_existants.stdout | from_json | map(attribute='Name') | list)
- name: lire le quota de calcul du projet
ansible.builtin.command: openstack quota show --compute {{ openstack_projet }} -f json
environment: *os_admin
register: openstack_quota_brut
changed_when: false
- name: régler le quota de calcul du projet
ansible.builtin.command: >-
openstack quota set --instances {{ openstack_projet_quota.instances }}
--cores {{ openstack_projet_quota.cores }} --ram {{ openstack_projet_quota.ram }}
{{ openstack_projet }}
environment: *os_admin
vars:
openstack_quota_actuel: >-
{{ dict(openstack_quota_brut.stdout | from_json | map(attribute='Resource') |
zip(openstack_quota_brut.stdout | from_json | map(attribute='Limit'))) }}
when: >-
openstack_quota_actuel.instances != openstack_projet_quota.instances or
openstack_quota_actuel.cores != openstack_projet_quota.cores or
openstack_quota_actuel.ram != openstack_projet_quota.ram

View file

@ -11,3 +11,14 @@ sauvegardes_repertoires:
- /root
sauvegardes_age_dir: /root/.age
sauvegardes_age_cle: /root/.age/cle-sauvegardes.txt
# Portail (ajouté à l'étape 13) : base exportée par mariadb-dump, et fichiers
# d'état hors de /etc (APP_KEY dans .env, futurs travaux d'élèves).
sauvegardes_portail_conteneur: portail_mariadb
sauvegardes_portail_base: portail
sauvegardes_portail_fichiers:
- /var/www/portail/.env
- /var/www/portail/storage/app
sauvegardes_image_mariadb: "mariadb:11"
sauvegardes_php_version: "8.3"
# Test de restauration hebdomadaire (CLAUDE.md : restauration testée chaque semaine).
sauvegardes_test_restauration_quand: "Mon *-*-* 04:30:00"

View file

@ -56,6 +56,42 @@
mode: "0644"
notify: recharger systemd
- name: déployer le test de restauration du portail (étape 13)
ansible.builtin.template:
src: syllabus-test-restauration.sh.j2
dest: /usr/local/sbin/syllabus-test-restauration
owner: root
group: root
mode: "0700"
- name: déployer le service et le minuteur du test de restauration
ansible.builtin.copy:
dest: "/etc/systemd/system/syllabus-test-restauration.{{ item.type }}"
mode: "0644"
content: "{{ item.contenu }}"
loop:
- type: service
contenu: |
# Géré par Ansible (rôle sauvegardes).
[Unit]
Description=Test de restauration de la dernière sauvegarde du portail
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/syllabus-test-restauration
- type: timer
contenu: |
# Géré par Ansible (rôle sauvegardes).
[Unit]
Description=Test de restauration hebdomadaire du portail
[Timer]
OnCalendar={{ sauvegardes_test_restauration_quand }}
Persistent=true
[Install]
WantedBy=timers.target
notify: recharger systemd
- name: appliquer les changements systemd
ansible.builtin.meta: flush_handlers
@ -65,6 +101,12 @@
enabled: true
state: started
- name: activer le minuteur du test de restauration
ansible.builtin.systemd:
name: syllabus-test-restauration.timer
enabled: true
state: started
- name: afficher la clé publique age (à connaître, jamais la privée)
ansible.builtin.debug:
msg: "Clé publique age du serveur : {{ sauvegardes_age_pub.stdout }}"

View file

@ -25,8 +25,25 @@ trap 'rm -rf "$WORKDIR"' EXIT
ARCHIVE_QUOT="quotidienne-${DATE}.tar.zst.age"
# Portail (étape 13) : export cohérent de sa base (--single-transaction, sans
# bloquer le site) et ses fichiers d'état. Le mot de passe root est lu dans
# le conteneur : il n'apparaît jamais sur la ligne de commande de l'hôte.
# Absent sur un serveur neuf (la sauvegarde arrive avant le portail) : ignoré.
EN_PLUS=()
if [ "$(docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ sauvegardes_portail_conteneur }} 2>/dev/null)" = "true" ]; then
docker exec {{ sauvegardes_portail_conteneur }} sh -c \
'MYSQL_PWD="$MARIADB_ROOT_PASSWORD" exec mariadb-dump -uroot --single-transaction --routines --triggers --databases {{ sauvegardes_portail_base }}' \
> "$WORKDIR/portail.sql"
EN_PLUS+=(-C "$WORKDIR" portail.sql)
fi
for chemin in {{ sauvegardes_portail_fichiers | join(' ') }}; do
if [ -e "$chemin" ]; then
EN_PLUS+=("$chemin")
fi
done
tar --exclude="{{ sauvegardes_age_dir }}" --exclude='/root/.dedibackup' \
-I zstd -cf - {{ sauvegardes_repertoires | join(' ') }} \
-I zstd -cf - {{ sauvegardes_repertoires | join(' ') }} "${EN_PLUS[@]}" \
| age -r "$AGE_PUB" -o "$WORKDIR/$ARCHIVE_QUOT"
lftp -u "$DEDIBACKUP_USER,$DEDIBACKUP_PASS" "$DEDIBACKUP_HOST" -e "

View file

@ -0,0 +1,101 @@
#!/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)"