package services import ( "context" "fmt" "os" "os/exec" "path/filepath" "time" "veza-backend-api/internal/utils" "go.uber.org/zap" ) // BackupService gère les sauvegardes de base de données // BE-DB-016: Implement automated database backups with retention policy type BackupService struct { logger *zap.Logger backupDir string retentionDays int databaseURL string databaseName string databaseUser string databaseHost string databasePort string } // BackupConfig contient la configuration pour les sauvegardes type BackupConfig struct { BackupDir string // Répertoire où stocker les backups RetentionDays int // Nombre de jours de rétention DatabaseURL string // URL de connexion à la base de données DatabaseName string // Nom de la base de données DatabaseUser string // Utilisateur de la base de données DatabaseHost string // Hôte de la base de données DatabasePort string // Port de la base de données } // BackupResult contient les informations sur une sauvegarde type BackupResult struct { Success bool BackupPath string BackupSize int64 Duration time.Duration ErrorMessage string CreatedAt time.Time } // NewBackupService crée un nouveau service de backup func NewBackupService(config BackupConfig, logger *zap.Logger) (*BackupService, error) { // Créer le répertoire de backup s'il n'existe pas if err := os.MkdirAll(config.BackupDir, 0755); err != nil { return nil, fmt.Errorf("failed to create backup directory: %w", err) } // Valeurs par défaut retentionDays := config.RetentionDays if retentionDays <= 0 { retentionDays = 30 // 30 jours par défaut } return &BackupService{ logger: logger, backupDir: config.BackupDir, retentionDays: retentionDays, databaseURL: config.DatabaseURL, databaseName: config.DatabaseName, databaseUser: config.DatabaseUser, databaseHost: config.DatabaseHost, databasePort: config.DatabasePort, }, nil } // CreateBackup crée une sauvegarde de la base de données func (bs *BackupService) CreateBackup(ctx context.Context) (*BackupResult, error) { startTime := time.Now() // Générer le nom du fichier de backup avec timestamp timestamp := time.Now().Format("20060102_150405") backupFileName := fmt.Sprintf("%s_%s.sql", bs.databaseName, timestamp) backupPath := filepath.Join(bs.backupDir, backupFileName) // SECURITY: Validate path for exec.Command if !utils.ValidateExecPath(backupPath) { return &BackupResult{ Success: false, ErrorMessage: "invalid backup path", Duration: time.Since(startTime), CreatedAt: time.Now(), }, fmt.Errorf("invalid backup path") } bs.logger.Info("Creating database backup", zap.String("database", bs.databaseName), zap.String("backup_path", backupPath)) // Construire la commande pg_dump // Utiliser pg_dump avec format custom pour compression var cmd *exec.Cmd if bs.databaseURL != "" { // Utiliser DATABASE_URL si disponible cmd = exec.CommandContext(ctx, "pg_dump", "-Fc", "-f", backupPath, bs.databaseURL) } else { // Utiliser les paramètres individuels args := []string{ "-h", bs.databaseHost, "-p", bs.databasePort, "-U", bs.databaseUser, "-Fc", // Format custom (compressed) "-f", backupPath, bs.databaseName, } cmd = exec.CommandContext(ctx, "pg_dump", args...) // Définir PGPASSWORD si nécessaire if password := os.Getenv("PGPASSWORD"); password != "" { cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", password)) } } // Exécuter la commande output, err := cmd.CombinedOutput() if err != nil { bs.logger.Error("Failed to create backup", zap.Error(err), zap.String("output", string(output))) return &BackupResult{ Success: false, ErrorMessage: fmt.Sprintf("pg_dump failed: %v: %s", err, string(output)), Duration: time.Since(startTime), CreatedAt: time.Now(), }, fmt.Errorf("backup failed: %w", err) } // Obtenir la taille du fichier fileInfo, err := os.Stat(backupPath) if err != nil { bs.logger.Warn("Failed to get backup file size", zap.Error(err)) } var backupSize int64 if fileInfo != nil { backupSize = fileInfo.Size() } duration := time.Since(startTime) bs.logger.Info("Backup created successfully", zap.String("backup_path", backupPath), zap.Int64("backup_size", backupSize), zap.Duration("duration", duration)) return &BackupResult{ Success: true, BackupPath: backupPath, BackupSize: backupSize, Duration: duration, CreatedAt: time.Now(), }, nil } // CleanupOldBackups supprime les anciennes sauvegardes selon la politique de rétention func (bs *BackupService) CleanupOldBackups(ctx context.Context) error { bs.logger.Info("Cleaning up old backups", zap.Int("retention_days", bs.retentionDays)) cutoffTime := time.Now().AddDate(0, 0, -bs.retentionDays) // Lire le répertoire de backup entries, err := os.ReadDir(bs.backupDir) if err != nil { return fmt.Errorf("failed to read backup directory: %w", err) } deletedCount := 0 for _, entry := range entries { if entry.IsDir() { continue } // Vérifier si le fichier correspond au pattern de backup ext := filepath.Ext(entry.Name()) if ext != ".sql" && ext != ".dump" { continue } // Obtenir les informations du fichier filePath := filepath.Join(bs.backupDir, entry.Name()) fileInfo, err := entry.Info() if err != nil { bs.logger.Warn("Failed to get file info", zap.String("file", entry.Name()), zap.Error(err)) continue } // Vérifier si le fichier est plus ancien que la date de rétention if fileInfo.ModTime().Before(cutoffTime) { if err := os.Remove(filePath); err != nil { bs.logger.Error("Failed to delete old backup", zap.String("file", entry.Name()), zap.Error(err)) continue } deletedCount++ bs.logger.Info("Deleted old backup", zap.String("file", entry.Name()), zap.Time("modified", fileInfo.ModTime())) } } bs.logger.Info("Cleanup completed", zap.Int("deleted_count", deletedCount)) return nil } // ListBackups liste toutes les sauvegardes disponibles func (bs *BackupService) ListBackups(ctx context.Context) ([]BackupInfo, error) { entries, err := os.ReadDir(bs.backupDir) if err != nil { return nil, fmt.Errorf("failed to read backup directory: %w", err) } var backups []BackupInfo for _, entry := range entries { if entry.IsDir() { continue } // Vérifier si le fichier correspond au pattern de backup ext := filepath.Ext(entry.Name()) if ext != ".sql" && ext != ".dump" { continue } filePath := filepath.Join(bs.backupDir, entry.Name()) fileInfo, err := entry.Info() if err != nil { continue } backups = append(backups, BackupInfo{ FileName: entry.Name(), FilePath: filePath, Size: fileInfo.Size(), CreatedAt: fileInfo.ModTime(), }) } return backups, nil } // BackupInfo contient les informations sur une sauvegarde type BackupInfo struct { FileName string FilePath string Size int64 CreatedAt time.Time }