loko/streetup/common/management/commands/backup_database.py
2026-07-22 14:48:40 +02:00

377 lines
16 KiB
Python

import os
import sys
import subprocess
import gzip
import shutil
from datetime import datetime, timedelta
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.utils import timezone
try:
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError, NoCredentialsError
BOTO3_AVAILABLE = True
except ImportError:
BOTO3_AVAILABLE = False
class Command(BaseCommand):
help = "Effectue une sauvegarde complète de la base de données PostgreSQL, puis effectue les transferts optionnels (Rsync, MinIO S3)."
def log_info(self, message: str):
self.stdout.write(f"[{timezone.now().strftime('%Y-%m-%d %H:%M:%S')}] [INFO] {message}")
def log_warning(self, message: str):
self.stdout.write(self.style.WARNING(f"[{timezone.now().strftime('%Y-%m-%d %H:%M:%S')}] [WARNING] {message}"))
def log_error(self, message: str):
self.stderr.write(self.style.ERROR(f"[{timezone.now().strftime('%Y-%m-%d %H:%M:%S')}] [ERROR] {message}"))
def get_backup_dir(self) -> Path:
"""Retourne le répertoire de sauvegarde, le crée si nécessaire."""
backup_dir = os.getenv("BACKUP_DIR")
if backup_dir:
backup_path = Path(backup_dir)
else:
# Répertoire 'backups' à la racine Django
backup_path = settings.BASE_DIR / "backups"
backup_path.mkdir(parents=True, exist_ok=True)
return backup_path
def get_db_config(self) -> dict:
"""Récupère la configuration de la base de données depuis l'environnement ou les settings Django."""
db_settings = settings.DATABASES.get("default", {})
# Priorité aux variables d'environnement, sinon repli sur la conf Django
config = {
"host": os.getenv("DB_HOST") or db_settings.get("HOST") or "localhost",
"port": os.getenv("DB_PORT") or db_settings.get("PORT") or "5432",
"name": os.getenv("DB_NAME") or db_settings.get("NAME"),
"user": os.getenv("DB_USER") or db_settings.get("USER"),
"password": os.getenv("DB_PASSWORD") or db_settings.get("PASSWORD"),
}
# Vérification des paramètres requis
missing = [key for key, value in config.items() if not value and key != "port"]
if missing:
raise CommandError(f"Paramètres de connexion à la base de données manquants: {', '.join(missing)}")
return config
def get_schemas(self) -> list:
"""Récupère les schémas PostgreSQL à sauvegarder."""
schemas_str = os.getenv("BACKUP_SCHEMAS")
if not schemas_str:
# Récupération automatique du schéma configuré
db_schema = os.getenv("DB_SCHEMA")
if not db_schema:
options = settings.DATABASES.get("default", {}).get("OPTIONS", {}).get("options", "")
if "search_path=" in options:
db_schema = options.split("search_path=")[1].split(",")[0].strip()
else:
db_schema = "django"
schemas_str = f"{db_schema},public"
return [s.strip() for s in schemas_str.split(",") if s.strip()]
def run_pg_dump(self, db_config: dict, output_file: Path, timeout_seconds: int, schemas: list) -> bool:
"""Exécute pg_dump pour créer la sauvegarde brute."""
pg_dump_path = os.getenv("PG_DUMP_PATH", "pg_dump")
cmd = [
pg_dump_path,
"-h", db_config["host"],
"-p", str(db_config["port"]),
"-U", db_config["user"],
"-d", db_config["name"],
"-F", "p", # Plain SQL format
"--no-password",
]
for schema in schemas:
cmd.extend(["-n", schema])
env = os.environ.copy()
env["PGPASSWORD"] = db_config["password"]
try:
self.log_info(f"Démarrage de pg_dump vers {output_file}")
self.log_info(f"Schémas : {', '.join(schemas)}")
with open(output_file, "w") as f:
result = subprocess.run(
cmd,
stdout=f,
stderr=subprocess.PIPE,
env=env,
timeout=timeout_seconds,
)
if result.returncode != 0:
err_msg = result.stderr.decode('utf-8', errors='replace')
self.log_error(f"Erreur pg_dump : {err_msg}")
if output_file.exists():
output_file.unlink()
raise CommandError(f"pg_dump a échoué (code {result.returncode}) : {err_msg}")
self.log_info("pg_dump terminé avec succès")
except subprocess.TimeoutExpired:
self.log_error(f"Timeout: pg_dump a dépassé la limite de {timeout_seconds}s")
if output_file.exists():
output_file.unlink()
raise CommandError(f"pg_dump a expiré après {timeout_seconds} secondes.")
except FileNotFoundError:
self.log_error(f"pg_dump introuvable à '{pg_dump_path}'. Assurez-vous que postgresql-client est installé.")
if output_file.exists():
output_file.unlink()
raise CommandError(f"pg_dump introuvable à '{pg_dump_path}'. Veuillez installer postgresql-client ou configurer PG_DUMP_PATH.")
except Exception as e:
self.log_error(f"Erreur inattendue durant pg_dump : {str(e)}")
if output_file.exists():
output_file.unlink()
raise CommandError(f"Erreur inattendue durant pg_dump : {str(e)}")
def compress_file(self, input_file: Path, output_file: Path):
"""Compresse la sauvegarde brute avec gzip."""
try:
self.log_info(f"Compression du dump vers {output_file}")
with open(input_file, "rb") as f_in:
with gzip.open(output_file, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)
# Suppression du fichier non compressé
input_file.unlink()
size_mb = output_file.stat().st_size / (1024 * 1024)
self.log_info(f"Compression terminée. Taille : {size_mb:.2f} Mo")
except Exception as e:
self.log_error(f"Erreur lors de la compression : {str(e)}")
raise CommandError(f"Erreur lors de la compression gzip : {str(e)}")
def upload_to_minio(self, backup_file: Path) -> bool:
"""Téléverse la sauvegarde sur le serveur MinIO configuré via l'API S3."""
if not BOTO3_AVAILABLE:
self.log_error("La librairie 'boto3' est requise pour le téléversement MinIO.")
return False
endpoint_url = os.getenv("BACKUP_MINIO_ENDPOINT_URL", "https://apps.mobility.brussels")
bucket = os.getenv("BACKUP_MINIO_BUCKET", "datatransfer")
region = os.getenv("BACKUP_MINIO_REGION", "us-east-1")
access_key = os.getenv("BACKUP_MINIO_ACCESS_KEY")
secret_key = os.getenv("BACKUP_MINIO_SECRET_KEY")
if not access_key or not secret_key:
self.log_error("BACKUP_MINIO_ACCESS_KEY ou BACKUP_MINIO_SECRET_KEY non configuré dans l'environnement. Téléversement impossible.")
return False
object_key = f"{datetime.now().date().isoformat()}_{backup_file.name}"
self.log_info(f"Téléversement vers MinIO ({endpoint_url}/{bucket}/{object_key})...")
s3 = boto3.client(
"s3",
endpoint_url=endpoint_url,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
try:
from boto3.s3.transfer import TransferConfig
# Configure le multipart upload avec des blocs de 5 Mo (minimum S3 requis)
# afin de contourner la limite de taille d'entité imposée par le proxy Nginx/Apache.
transfer_config = TransferConfig(
multipart_threshold=5 * 1024 * 1024,
multipart_chunksize=5 * 1024 * 1024,
)
s3.upload_file(
Filename=str(backup_file),
Bucket=bucket,
Key=object_key,
Config=transfer_config
)
self.log_info("Téléversement MinIO réussi.")
return True
except NoCredentialsError:
self.log_error("Identifiants non valides pour MinIO/S3.")
return False
except ClientError as e:
self.log_error(f"Échec MinIO ClientError : {e}")
return False
except Exception as e:
self.log_error(f"Erreur inattendue durant l'upload MinIO : {e}")
return False
def sync_to_remote(self, backup_file: Path) -> bool:
"""Effectue le transfert distant historique (rsync) s'il est activé."""
remote_enabled = os.getenv("BACKUP_REMOTE_ENABLED", "false").lower() == "true"
if not remote_enabled:
return True
remote_host = os.getenv("BACKUP_REMOTE_HOST")
remote_dir = os.getenv("BACKUP_REMOTE_DIR")
ssh_key = os.getenv("BACKUP_SSH_KEY")
# Si le chemin configuré n'existe pas dans le conteneur mais qu'un montage volume
# a mis la clé dans /app/backup_key, on se replie sur ce montage.
if ssh_key and not os.path.exists(ssh_key) and os.path.exists("/app/backup_key"):
self.log_info("Repli automatique sur la clé SSH montée dans le conteneur (/app/backup_key)")
ssh_key = "/app/backup_key"
timeout = int(os.getenv("BACKUP_REMOTE_TIMEOUT", "3600"))
if not remote_host or not remote_dir:
self.log_warning("BACKUP_REMOTE_HOST ou BACKUP_REMOTE_DIR non configuré. Transfert Rsync ignoré.")
return False
self.log_info(f"Transfert Rsync vers {remote_host}:{remote_dir}")
# Préparer une clé SSH temporaire avec des permissions 600 pour contourner
# les restrictions de permissions d'SSH sur les clés à droits trop larges (ex: 644)
tmp_ssh_key = None
if ssh_key and os.path.exists(ssh_key):
try:
import tempfile
fd, tmp_ssh_path = tempfile.mkstemp()
with os.fdopen(fd, 'wb') as tmp_file:
with open(ssh_key, 'rb') as src_file:
tmp_file.write(src_file.read())
os.chmod(tmp_ssh_path, 0o600)
tmp_ssh_key = tmp_ssh_path
self.log_info(f"Clé SSH copiée temporairement avec les permissions 600 : {tmp_ssh_key}")
except Exception as e:
self.log_warning(f"Impossible de sécuriser la clé SSH pour le conteneur: {e}. Utilisation directe.")
tmp_ssh_key = ssh_key
else:
tmp_ssh_key = ssh_key
ssh_cmd = "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
if tmp_ssh_key:
ssh_cmd = f"ssh -i {tmp_ssh_key} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
cmd = [
"rsync",
"-avz",
"-e", ssh_cmd,
str(backup_file),
f"{remote_host}:{remote_dir}/",
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
self.log_error(f"Erreur Rsync : {result.stderr}")
return False
self.log_info("Transfert Rsync réussi.")
return True
except subprocess.TimeoutExpired:
self.log_error(f"Timeout: Rsync a dépassé {timeout}s.")
return False
except FileNotFoundError:
self.log_error("rsync non trouvé sur le système.")
return False
except Exception as e:
self.log_error(f"Erreur Rsync inattendue : {str(e)}")
return False
finally:
if tmp_ssh_key and tmp_ssh_key != ssh_key and os.path.exists(tmp_ssh_key):
try:
os.unlink(tmp_ssh_key)
except OSError:
pass
def cleanup_old_backups(self, backup_dir: Path, retention_days: int):
"""Supprime les sauvegardes expirées locales."""
cutoff_date = datetime.now() - timedelta(days=retention_days)
deleted_count = 0
self.log_info(f"Nettoyage des sauvegardes locales de plus de {retention_days} jours")
for backup_file in backup_dir.glob("streetup_backup_*.sql.gz"):
try:
date_str = backup_file.name.replace("streetup_backup_", "").replace(".sql.gz", "")
# Format attendu : YYYYMMDD_HHMMSS
file_date = datetime.strptime(date_str, "%Y%m%d_%H%M%S")
if file_date < cutoff_date:
backup_file.unlink()
self.log_info(f"Supprimé : {backup_file.name}")
deleted_count += 1
except (ValueError, OSError) as e:
self.log_warning(f"Impossible de traiter/supprimer {backup_file.name} : {str(e)}")
if deleted_count > 0:
self.log_info(f"{deleted_count} sauvegarde(s) ancienne(s) supprimée(s).")
else:
self.log_info("Aucune sauvegarde expirée à supprimer.")
def handle(self, *args, **options):
# Vérification globale de l'activation des sauvegardes (évite les lancements indésirables en dev/test après import de dump)
backup_enabled = os.getenv("BACKUP_ENABLED", "false").lower() == "true"
if not backup_enabled:
self.log_warning("La sauvegarde de la base de données est désactivée via la variable d'environnement BACKUP_ENABLED.")
return
self.log_info("=" * 60)
self.log_info("SAUVEGARDE DE LA BASE DE DONNÉES STREETUP")
self.log_info("=" * 60)
db_config = self.get_db_config()
backup_dir = self.get_backup_dir()
retention_days = int(os.getenv("BACKUP_RETENTION_DAYS", "30"))
timeout_seconds = int(os.getenv("BACKUP_TIMEOUT_SECONDS", "3600"))
schemas = self.get_schemas()
# Configuration des transferts
minio_enabled = os.getenv("BACKUP_MINIO_ENABLED", "false").lower() == "true"
remote_enabled = os.getenv("BACKUP_REMOTE_ENABLED", "false").lower() == "true"
self.log_info(f"Base de données : {db_config['name']} @ {db_config['host']}")
self.log_info(f"Dossier local : {backup_dir}")
self.log_info(f"Rétention locale : {retention_days} jours")
self.log_info(f"Schémas : {', '.join(schemas)}")
self.log_info(f"Upload MinIO : {'ACTIVÉ' if minio_enabled else 'DÉSACTIVÉ'}")
self.log_info(f"Transfert Rsync : {'ACTIVÉ' if remote_enabled else 'DÉSACTIVÉ'}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
sql_file = backup_dir / f"streetup_backup_{timestamp}.sql"
gz_file = backup_dir / f"streetup_backup_{timestamp}.sql.gz"
# 1. pg_dump
self.run_pg_dump(db_config, sql_file, timeout_seconds, schemas)
# 2. gzip
self.compress_file(sql_file, gz_file)
# 3. MinIO
if minio_enabled:
if not self.upload_to_minio(gz_file):
self.log_warning("Sauvegarde MinIO en échec (le backup local est conservé).")
# 4. Rsync
if remote_enabled:
if not self.sync_to_remote(gz_file):
self.log_warning("Sauvegarde Rsync en échec (le backup local est conservé).")
# 5. Clean local
self.cleanup_old_backups(backup_dir, retention_days)
self.log_info("-" * 60)
self.log_info(f"✓ Sauvegarde exécutée avec succès : {gz_file.name}")
self.log_info("=" * 60)