Aller au contenu

Sauvegarde et restauration

Guide complet pour sauvegarder et restaurer une instance Repod en production.


Vue d'ensemble

Depuis la migration vers PostgreSQL (commit 6a711dc), toutes les données relationnelles (utilisateurs, index des manifestes, inventaire, jobs d'installation, recherche de paquets, empreintes SSH ssh_known_hosts) vivent dans une seule base PostgreSQL, accessible via DATABASE_URL. Le système de fichiers /repos/ ne contient plus que les données non relationnelles.

Élément Emplacement Criticité Notes
Base applicative (utilisateurs, manifestes, inventaire, CVE, jobs) PostgreSQL (DATABASE_URL, volume postgres_data) 🔴 Critique Sauvegardée via pg_dump (format custom, restaurable avec pg_restore)
Configuration repos/settings.json 🔴 Critique LDAP, OIDC, politique CVE, mirror, backup
Manifestes paquets repos/manifests/ 🟠 Important Métadonnées de chaque paquet (cache — reconstructible depuis pool/)
Logs d'audit repos/audit/ 🟠 Important Traçabilité réglementaire
Clés GPG repos/gnupg/ 🔴 Critique Perte = re-signature de tous les paquets
Pool de paquets repos/pool/ 🟡 Optionnel .deb/.rpm/.apk — peut être reconstitué depuis les sources, mais coûteux
Décisions CVE / cache KEV/EPSS repos/security/ 🟠 Important Décisions de revue RSSI et caches CVE

Deux mécanismes de sauvegarde sont disponibles et produisent le même format d'archive (repod_backup_TIMESTAMP.tar.gz) :

  1. Sauvegarde intégrée (recommandée) — déclenchable par un admin via l'API ou l'interface (Paramètres → Sauvegardes), et planifiable via le scheduler (backup_daily, cron job services/backup.py).
  2. Script backup.sh — à la racine du projet, pour les sauvegardes externes (cron host, NAS).

Sauvegarde intégrée (admin / scheduler)

services/backup.py:create_backup() produit une archive .tar.gz dans BACKUP_DIR (défaut /repos/backups), contenant :

repod_backup_YYYYMMDD_HHMMSS/
├── postgres.dump      ← pg_dump -F c (format custom, restaurable par pg_restore)
├── settings.json
├── audit/
├── security/
├── manifests/
├── gnupg/             ← permissions préservées
└── pool/

create_backup() lève une erreur explicite si DATABASE_URL ou pg_dump est absent — il n'y a jamais d'archive "incomplète" silencieuse.

Déclencher une sauvegarde manuelle

TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"VotreMotDePasse"}' | jq -r .access_token)

# Créer une sauvegarde
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/backup/

# Lister les sauvegardes existantes
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/backup/

# Télécharger une archive
curl -H "Authorization: Bearer $TOKEN" \
  -o repod_backup_20260601_020000.tar.gz \
  http://localhost:8000/api/v1/backup/repod_backup_20260601_020000.tar.gz

# Supprimer une archive
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/api/v1/backup/repod_backup_20260601_020000.tar.gz

Tous ces endpoints exigent le rôle admin.

Planification automatique

Dans Paramètres → Sauvegardes :

  • backup.enabled — active le job backup_daily (APScheduler)
  • backup.hour / backup.minute — heure d'exécution (défaut 04:30)
  • backup.retention_count — nombre d'archives conservées (apply_retention() supprime les plus anciennes au-delà de ce nombre)

Le job est journalisé dans l'audit log (BACKUP_CREATE, SUCCESS/FAILURE).


Script backup.sh (sauvegarde externe)

Pour les sauvegardes déclenchées depuis l'hôte (cron, NAS), backup.sh à la racine du projet sauvegarde les mêmes éléments via pg_dump :

# Sauvegarde dans ./backups/ (répertoire par défaut)
DATABASE_URL=postgresql://repod:CHANGE_MOI@localhost:5432/repod ./backup.sh

# Sauvegarde vers un NAS ou volume externe
DATABASE_URL=postgresql://... BACKUP_DIR=/mnt/nas/repod ./backup.sh

# Mode simulation (aucune écriture)
./backup.sh --dry-run

# Rétention personnalisée (défaut : 30 jours)
DATABASE_URL=postgresql://... BACKUP_RETENTION_DAYS=90 BACKUP_DIR=/mnt/nas/repod ./backup.sh

backup.sh requiert DATABASE_URL et pg_dump — il échoue explicitement (fail) si l'un des deux est absent plutôt que de produire une archive sans base de données.

Ce que le script sauvegarde

repod_backup_YYYYMMDD_HHMMSS.tar.gz
├── postgres.dump      ← pg_dump -F c (toutes les données relationnelles)
├── pool/               ← .deb / .rpm / .apk
├── settings.json
├── audit/              ← tous les fichiers *.jsonl
├── security/           ← décisions CVE, caches KEV/EPSS
├── manifests/          ← métadonnées de chaque paquet
└── gnupg/              ← trousseau GPG (permissions 700 préservées)

Installations antérieures à la migration PostgreSQL

Si repos/auth/users.db existe encore (installation pré-migration non nettoyée), backup.sh le sauvegarde également (users.db, via sqlite3 .backup si disponible) pour ne pas perdre de données legacy. Les installations actuelles n'ont pas ce fichier.

Planification avec cron

# Sauvegarde quotidienne à 2h00 avec rétention 90 jours
0 2 * * * cd /opt/repod && DATABASE_URL=postgresql://repod:CHANGE_MOI@localhost:5432/repod \
  BACKUP_DIR=/mnt/nas/repod BACKUP_RETENTION_DAYS=90 ./backup.sh >> /var/log/repod-backup.log 2>&1

Connexion à PostgreSQL depuis l'hôte

Le service db (PostgreSQL) n'est pas publié sur l'hôte par défaut. Pour que backup.sh exécuté côté hôte puisse se connecter, soit publiez temporairement le port 5432 du conteneur db, soit exécutez pg_dump depuis l'intérieur du conteneur repod-db :

docker exec repod-db pg_dump -U repod -F c -f /tmp/postgres.dump repod
docker cp repod-db:/tmp/postgres.dump ./postgres.dump

Chiffrement GPG du backup (recommandé en production)

# Générer une clé de backup dédiée (à faire une seule fois)
gpg --batch --generate-key <<EOF
%no-protection
Key-Type: RSA
Key-Length: 4096
Name-Real: Repod Backup Key
Name-Email: [email protected]
Expire-Date: 2y
EOF

# Chiffrer l'archive après backup
ARCHIVE=$(ls -t backups/repod_backup_*.tar.gz | head -1)
gpg --recipient [email protected] --encrypt "$ARCHIVE"
# Résultat : repod_backup_TIMESTAMP.tar.gz.gpg
rm "$ARCHIVE"

# Stocker la clé privée GPG séparément du backup (coffre-fort, KMS…)
gpg --export-secret-keys [email protected] | gpg --symmetric --output backup-key.gpg.enc

Procédure de restauration

Arrêtez le backend avant toute restauration

pg_restore --clean supprime et recrée les objets de la base. Arrêtez le conteneur backend-api (ou la pile entière) avant de restaurer pour éviter les écritures concurrentes.

Étape 1 — Arrêter les services

cd /opt/repod
docker compose down

Étape 2 — Extraire l'archive

ARCHIVE="repod_backup_20260601_020000.tar.gz"
BACKUP_NAME="${ARCHIVE%.tar.gz}"
RESTORE_TMP="/tmp/repod_restore"

# Si chiffré GPG, déchiffrer d'abord
# gpg --decrypt "$ARCHIVE.gpg" > "$ARCHIVE"

mkdir -p "$RESTORE_TMP"
tar -xzf "$ARCHIVE" -C "$RESTORE_TMP"
ls "$RESTORE_TMP/$BACKUP_NAME/"

Étape 3 — Démarrer uniquement PostgreSQL et restaurer la base

cd /opt/repod
docker compose up -d db
sleep 5   # attendre que PostgreSQL accepte les connexions

# pg_restore --clean recrée les objets ; --if-exists évite les erreurs si la base est vide
docker exec -i repod-db pg_restore -U repod -d repod --clean --if-exists \
  < "$RESTORE_TMP/$BACKUP_NAME/postgres.dump"

Étape 4 — Restaurer la configuration et les données /repos/

REPOS_DIR="/opt/repod/repos"
RESTORE_DIR="$RESTORE_TMP/$BACKUP_NAME"

# settings.json — sauvegarder l'actuel par précaution
cp "$REPOS_DIR/settings.json" "$REPOS_DIR/settings.json.before-restore" 2>/dev/null || true
cp "$RESTORE_DIR/settings.json" "$REPOS_DIR/settings.json"

# Audit logs — fusionner sans écraser (conserver les logs post-incident)
[ -d "$RESTORE_DIR/audit" ] && cp -rn "$RESTORE_DIR/audit/." "$REPOS_DIR/audit/"

# Manifestes
[ -d "$RESTORE_DIR/manifests" ] && cp -r "$RESTORE_DIR/manifests/." "$REPOS_DIR/manifests/"

# Décisions CVE / caches sécurité
[ -d "$RESTORE_DIR/security" ] && cp -r "$RESTORE_DIR/security/." "$REPOS_DIR/security/"

# Pool de paquets
[ -d "$RESTORE_DIR/pool" ] && cp -r "$RESTORE_DIR/pool/." "$REPOS_DIR/pool/"

# Clés GPG — préserver les permissions
if [ -d "$RESTORE_DIR/gnupg" ]; then
    cp -rp "$RESTORE_DIR/gnupg/." "$REPOS_DIR/gnupg/"
    chmod 700 "$REPOS_DIR/gnupg"
fi

Étape 5 — Régénérer les arborescences de dépôt

Les arborescences dists/ (APT), rpm/ (RPM) et apk/ (Alpine) ne sont pas incluses dans la sauvegarde — elles sont régénérées depuis pool/ et la base PostgreSQL restaurée :

cd /opt/repod
docker compose up -d
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"VotreMotDePasse"}' | jq -r .access_token)

curl -X POST http://localhost:8000/api/v1/distributions/init \
  -H "Authorization: Bearer $TOKEN"

Étape 6 — Vérifier

curl -s http://localhost:8000/health | python3 -m json.tool

# Vérifier la connexion admin
curl -s -X POST http://localhost:8000/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"VotreMotDePasse"}'

Vérification de l'intégrité du backup

Vérifier qu'un backup est valide sans effectuer de restauration :

ARCHIVE="repod_backup_20260601_020000.tar.gz"
RESTORE_TMP=$(mktemp -d)

tar -xzf "$ARCHIVE" -C "$RESTORE_TMP"
BACKUP_DIR="$RESTORE_TMP/$(basename "$ARCHIVE" .tar.gz)"

# Vérifier le dump PostgreSQL (liste le contenu sans restaurer)
if pg_restore --list "$BACKUP_DIR/postgres.dump" > /dev/null 2>&1; then
    echo "✅ postgres.dump : archive valide"
else
    echo "❌ postgres.dump : CORROMPU"
fi

# settings.json
if python3 -m json.tool "$BACKUP_DIR/settings.json" > /dev/null 2>&1; then
    echo "✅ settings.json : JSON valide"
else
    echo "❌ settings.json : JSON invalide"
fi

# Manifestes
MANIFEST_COUNT=$(find "$BACKUP_DIR/manifests" -name "*.json" 2>/dev/null | wc -l)
echo "📦 Manifestes : $MANIFEST_COUNT fichiers"

# Audit logs
AUDIT_COUNT=$(find "$BACKUP_DIR/audit" -name "*.jsonl" 2>/dev/null | wc -l)
echo "📋 Audit logs : $AUDIT_COUNT fichiers"

# Clés GPG
if [ -d "$BACKUP_DIR/gnupg" ]; then
    echo "🔑 GPG : répertoire présent"
else
    echo "⚠️  GPG : absent du backup"
fi

rm -rf "$RESTORE_TMP"

Stratégie de backup recommandée (3-2-1)

La règle 3-2-1 est la référence de l'industrie :

Règle Description Implémentation recommandée
3 copies Au moins 3 copies des données Local + NAS + cloud
2 supports différents Sur 2 types de médias distincts Disque local + NAS ou bande
1 hors site Au moins 1 copie hors site S3 / cloud chiffré GPG
# Exemple : envoi vers S3 après backup
ARCHIVE=$(ls -t backups/repod_backup_*.tar.gz | head -1)
aws s3 cp "$ARCHIVE" s3://mon-bucket-backup/repod/

Plan de reprise d'activité (PRA)

Objectifs de reprise

Indicateur Valeur cible Notes
RPO (Recovery Point Objective) ≤ 24 heures Backup quotidien recommandé
RTO (Recovery Time Objective) ≤ 2 heures Temps de restauration complète

Scénarios couverts

Scénario 1 — Corruption de la base PostgreSQL

Symptôme : backend-api échoue au démarrage avec une erreur SQLAlchemy/Alembic, ou GET /health renvoie checks.critical.auth_db / manifest_db en échec.

ls -lt backups/repod_backup_*.tar.gz

# Suivre les étapes 1 à 3 de la procédure de restauration ci-dessus
# (arrêt des services, extraction, pg_restore --clean --if-exists)

Scénario 2 — Perte des clés GPG

Situation critique

La perte des clés GPG rend les paquets déjà signés non vérifiables par les clients apt/dnf/zypper/apk. La restauration des clés depuis le backup est la seule solution sans re-signer tous les paquets.

ARCHIVE="repod_backup_20260601_020000.tar.gz"
tar -xzf "$ARCHIVE" --strip-components=1 -C /tmp/ "*/gnupg"
cp -rp /tmp/gnupg/. repos/gnupg/
chmod 700 repos/gnupg
docker compose restart backend-api

Scénario 3 — Sinistre total (serveur perdu)

  1. Provisionner un nouveau serveur (voir guide de déploiement)
  2. Installer Docker + Docker Compose
  3. Cloner le dépôt Repod
  4. Créer .env et backend.env depuis les fichiers .example (DATABASE_URL, POSTGRES_PASSWORD, JWT_SECRET_KEY, SETTINGS_ENCRYPTION_KEY, WEBHOOK_SECRET, REPO_FORMAT)
  5. Démarrer uniquement db, puis suivre les étapes 2 à 5 de la procédure de restauration ci-dessus
  6. Vérifier le health check et les comptes administrateurs
  7. Mettre à jour le DNS / reverse proxy si l'IP a changé
  8. Notifier les équipes (RSSI, ops) et documenter l'incident

Scénario 4 — Rollback de configuration

ARCHIVE="repod_backup_20260601_020000.tar.gz"
tar -xzf "$ARCHIVE" --strip-components=1 -C /tmp/ "*/settings.json"
cp repos/settings.json repos/settings.json.rollback-$(date +%Y%m%d)
cp /tmp/settings.json repos/settings.json
docker compose restart backend-api

Tests de restauration

Testez votre backup au moins une fois par trimestre

Un backup non testé est un backup dont on ne connaît pas l'état réel.

# Procédure de test sur un serveur de staging
# 1. Copier le dernier backup sur le serveur de staging
scp /mnt/nas/repod/repod_backup_latest.tar.gz staging:/tmp/

# 2. Sur le staging, suivre la procédure de restauration ci-dessus

# 3. Vérifier que le service est opérationnel
ssh staging "curl -s http://localhost:8000/health"

# 4. Vérifier qu'un login admin fonctionne
# 5. Vérifier qu'un paquet est visible dans l'interface
# 6. Documenter le test (date, durée, résultat)

Variables d'environnement du backup

Variable Défaut Description
DATABASE_URL Requis par backup.sh et la sauvegarde intégrée — chaîne de connexion PostgreSQL
BACKUP_DIR /repos/backups (intégré) / ./backups (backup.sh) Répertoire de destination des archives
REPOS_DIR /repos (intégré) / ./repos (backup.sh) Répertoire source des données non relationnelles
BACKUP_RETENTION_DAYS 30 (backup.sh) Durée de rétention des archives locales (0 = pas de purge)
settings.json["backup"]["retention_count"] 7 (sauvegarde intégrée) Nombre d'archives conservées par apply_retention()

Checklist opérationnelle

Mise en place initiale

  • Activer backup.enabled dans Paramètres → Sauvegardes (sauvegarde intégrée quotidienne)
  • Et/ou configurer un cron daily pour backup.sh avec DATABASE_URL défini
  • Définir BACKUP_DIR vers un volume externe (NAS, S3...) pour backup.sh
  • Configurer le chiffrement GPG du backup
  • Documenter l'emplacement de la clé GPG de backup (coffre-fort, KMS)
  • Définir la rétention selon la politique RGPD
  • Tester une restauration complète sur environnement de staging
  • Documenter le RTO/RPO réel mesuré lors du test

Contrôle mensuel

  • Vérifier que les sauvegardes (intégrées et/ou cron) se sont bien exécutées
  • Vérifier l'intégrité d'au moins 1 archive (pg_restore --list)
  • Vérifier l'espace disque disponible sur la destination backup
  • Vérifier que la rotation des anciennes archives fonctionne

En cas d'incident

  • Identifier le dernier backup valide avant l'incident
  • Évaluer le RPO (données perdues depuis ce backup)
  • Suivre la procédure de restauration adaptée au scénario
  • Documenter l'incident et la restauration dans le registre des incidents
  • Si données personnelles impactées : notifier la CNIL dans les 72h (Art. 33 RGPD)