package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.backup.request.CreateBackupRequest; import dev.lions.unionflow.server.api.dto.backup.request.RestoreBackupRequest; import dev.lions.unionflow.server.api.dto.backup.request.UpdateBackupConfigRequest; import dev.lions.unionflow.server.api.dto.backup.response.BackupConfigResponse; import dev.lions.unionflow.server.api.dto.backup.response.BackupResponse; import dev.lions.unionflow.server.entity.BackupConfig; import dev.lions.unionflow.server.entity.BackupRecord; import dev.lions.unionflow.server.repository.BackupConfigRepository; import dev.lions.unionflow.server.repository.BackupRecordRepository; import io.quarkus.security.identity.SecurityIdentity; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; import lombok.extern.slf4j.Slf4j; import org.eclipse.microprofile.config.inject.ConfigProperty; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** * Service de gestion des sauvegardes système. * Persiste les métadonnées en base et exécute pg_dump pour les sauvegardes DB. */ @Slf4j @ApplicationScoped public class BackupService { private static final Pattern JDBC_PATTERN = Pattern.compile("jdbc:postgresql://([^:/]+):(\\d+)/([^?]+)"); @Inject SecurityIdentity securityIdentity; @Inject BackupRecordRepository backupRecordRepository; @Inject BackupConfigRepository backupConfigRepository; @ConfigProperty(name = "unionflow.backup.directory", defaultValue = "/tmp/unionflow-backups") String backupDirectory; @ConfigProperty(name = "quarkus.datasource.jdbc.url") String jdbcUrl; @ConfigProperty(name = "quarkus.datasource.username") String dbUsername; @ConfigProperty(name = "quarkus.datasource.password") String dbPassword; // ==================== API PUBLIQUE ==================== /** * Lister toutes les sauvegardes disponibles. */ public List getAllBackups() { log.debug("Récupération de toutes les sauvegardes"); return backupRecordRepository.findAllOrderedByDate() .stream() .map(this::mapToResponse) .collect(Collectors.toList()); } /** * Récupérer une sauvegarde par ID. */ public BackupResponse getBackupById(UUID id) { log.debug("Récupération de la sauvegarde: {}", id); return backupRecordRepository.findByIdOptional(id) .map(this::mapToResponse) .orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + id)); } /** * Créer une nouvelle sauvegarde et exécuter pg_dump si la DB est incluse. */ @Transactional public BackupResponse createBackup(CreateBackupRequest request) { log.info("Création d'une nouvelle sauvegarde: {}", request.getName()); String createdBy = securityIdentity.getPrincipal() != null ? securityIdentity.getPrincipal().getName() : "system"; boolean includesDb = request.getIncludeDatabase() == null || request.getIncludeDatabase(); boolean includesFiles = Boolean.TRUE.equals(request.getIncludeFiles()); boolean includesConf = request.getIncludeConfiguration() == null || request.getIncludeConfiguration(); String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm")); String type = request.getType() != null ? request.getType() : "MANUAL"; String filePath = backupDirectory + "/" + type.toLowerCase() + "-" + timestamp + ".dump"; BackupRecord record = BackupRecord.builder() .name(request.getName()) .description(request.getDescription()) .type(type) .status("IN_PROGRESS") .createdBy(createdBy) .includesDatabase(includesDb) .includesFiles(includesFiles) .includesConfiguration(includesConf) .filePath(filePath) .build(); backupRecordRepository.persist(record); // Execute backup synchronously (admin-triggered operation) long sizeBytes = 0L; String errorMessage = null; boolean success = true; try { if (includesDb) { Files.createDirectories(Paths.get(backupDirectory)); success = executePgDump(filePath); if (success) { File backupFile = new File(filePath); sizeBytes = backupFile.exists() ? backupFile.length() : 0L; } else { errorMessage = "pg_dump failed — check server logs"; } } } catch (IOException e) { log.error("Erreur lors de la création du répertoire de sauvegarde", e); success = false; errorMessage = "Cannot create backup directory: " + e.getMessage(); } record.setStatus(success ? "COMPLETED" : "FAILED"); record.setSizeBytes(sizeBytes); record.setCompletedAt(LocalDateTime.now()); record.setErrorMessage(errorMessage); log.info("Sauvegarde {} avec statut {}: {}", record.getId(), record.getStatus(), filePath); return mapToResponse(record); } /** * Restaurer une sauvegarde (crée un point de restauration au préalable si demandé). */ @Transactional public void restoreBackup(RestoreBackupRequest request) { log.info("Restauration de la sauvegarde: {}", request.getBackupId()); BackupRecord backup = backupRecordRepository.findByIdOptional(request.getBackupId()) .orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + request.getBackupId())); if (!"COMPLETED".equals(backup.getStatus())) { throw new RuntimeException("La sauvegarde doit être complétée pour être restaurée"); } if (Boolean.TRUE.equals(request.getCreateRestorePoint())) { log.info("Création d'un point de restauration avant la restauration"); createBackup(CreateBackupRequest.builder() .name("Point de restauration") .description("Avant restauration de: " + backup.getName()) .type("RESTORE_POINT") .includeDatabase(true) .includeFiles(false) .includeConfiguration(true) .build()); } // pg_restore requires the application to be stopped — log the command instead log.warn("Restauration initiée pour le fichier: {}", backup.getFilePath()); log.warn("Exécutez manuellement : pg_restore -h -U -d -Fc {}", backup.getFilePath()); log.info("- Database: {}, Files: {}, Configuration: {}", request.getRestoreDatabase(), request.getRestoreFiles(), request.getRestoreConfiguration()); } /** * Supprimer une sauvegarde (fichier physique + soft-delete du record). */ @Transactional public void deleteBackup(UUID id) { log.info("Suppression de la sauvegarde: {}", id); BackupRecord backup = backupRecordRepository.findByIdOptional(id) .orElseThrow(() -> new RuntimeException("Sauvegarde non trouvée: " + id)); // Supprimer le fichier physique si présent if (backup.getFilePath() != null) { File file = new File(backup.getFilePath()); if (file.exists() && file.isFile()) { boolean deleted = file.delete(); if (deleted) { log.info("Fichier physique supprimé: {}", backup.getFilePath()); } else { log.warn("Impossible de supprimer le fichier: {}", backup.getFilePath()); } } } // Soft delete via BaseEntity.actif backup.setActif(false); log.info("Sauvegarde {} supprimée (soft delete)", id); } /** * Récupérer la configuration des sauvegardes automatiques. */ @Transactional public BackupConfigResponse getBackupConfig() { log.debug("Récupération de la configuration des sauvegardes"); BackupConfig config = backupConfigRepository.getConfig() .orElseGet(this::createDefaultBackupConfig); return buildConfigResponse(config); } /** * Mettre à jour la configuration des sauvegardes automatiques. */ @Transactional public BackupConfigResponse updateBackupConfig(UpdateBackupConfigRequest request) { log.info("Mise à jour de la configuration des sauvegardes"); BackupConfig config = backupConfigRepository.getConfig() .orElseGet(this::createDefaultBackupConfig); if (request.getAutoBackupEnabled() != null) config.setAutoBackupEnabled(request.getAutoBackupEnabled()); if (request.getFrequency() != null) config.setFrequency(request.getFrequency()); if (request.getRetentionDays() != null) config.setRetentionDays(request.getRetentionDays()); if (request.getBackupTime() != null) config.setBackupTime(request.getBackupTime()); if (request.getIncludeDatabase() != null) config.setIncludeDatabase(request.getIncludeDatabase()); if (request.getIncludeFiles() != null) config.setIncludeFiles(request.getIncludeFiles()); if (request.getIncludeConfiguration() != null) config.setIncludeConfiguration(request.getIncludeConfiguration()); return buildConfigResponse(config); } /** * Créer un point de restauration avant une opération critique. */ @Transactional public BackupResponse createRestorePoint() { log.info("Création d'un point de restauration"); return createBackup(CreateBackupRequest.builder() .name("Point de restauration") .description("Point de restauration créé le " + LocalDateTime.now()) .type("RESTORE_POINT") .includeDatabase(true) .includeFiles(false) .includeConfiguration(true) .build()); } // ==================== MÉTHODES PRIVÉES ==================== /** * Exécute pg_dump vers le fichier spécifié. * @return true si pg_dump s'est terminé avec le code 0 */ private boolean executePgDump(String filePath) { Matcher m = JDBC_PATTERN.matcher(jdbcUrl); if (!m.find()) { log.error("Impossible de parser l'URL JDBC: {}", jdbcUrl); return false; } String dbHost = m.group(1); String dbPort = m.group(2); String dbName = m.group(3); try { ProcessBuilder pb = new ProcessBuilder( "pg_dump", "-h", dbHost, "-p", dbPort, "-U", dbUsername, "-Fc", // custom format (compressed) "-f", filePath, dbName ); pb.environment().put("PGPASSWORD", dbPassword); pb.redirectErrorStream(true); log.info("Lancement de pg_dump: {} → {}", dbName, filePath); Process process = pb.start(); String output = new String(process.getInputStream().readAllBytes()); int exitCode = process.waitFor(); if (exitCode != 0) { log.error("pg_dump a échoué (code {}): {}", exitCode, output); return false; } log.info("pg_dump terminé avec succès pour {}", dbName); return true; } catch (IOException e) { log.error("pg_dump introuvable ou erreur d'exécution — vérifiez que pg_dump est dans le PATH", e); return false; } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("pg_dump interrompu", e); return false; } } private BackupConfig createDefaultBackupConfig() { BackupConfig config = BackupConfig.builder() .autoBackupEnabled(true) .frequency("DAILY") .retentionDays(30) .backupTime("02:00") .includeDatabase(true) .includeFiles(false) .includeConfiguration(true) .backupDirectory(backupDirectory) .build(); backupConfigRepository.persist(config); return config; } private BackupConfigResponse buildConfigResponse(BackupConfig config) { long totalBackups = backupRecordRepository.count("status = ?1 AND actif = ?2", "COMPLETED", true); long totalSizeBytes = backupRecordRepository .find("status = ?1 AND actif = ?2 AND sizeBytes IS NOT NULL", "COMPLETED", true) .list() .stream() .mapToLong(r -> r.getSizeBytes() != null ? r.getSizeBytes() : 0L) .sum(); LocalDateTime lastBackup = backupRecordRepository .find("status = ?1 AND actif = ?2 ORDER BY completedAt DESC", "COMPLETED", true) .firstResultOptional() .map(BackupRecord::getCompletedAt) .orElse(null); return BackupConfigResponse.builder() .autoBackupEnabled(config.getAutoBackupEnabled()) .frequency(config.getFrequency()) .retention(config.getRetentionDays() + " jours") .retentionDays(config.getRetentionDays()) .backupTime(config.getBackupTime()) .includeDatabase(config.getIncludeDatabase()) .includeFiles(config.getIncludeFiles()) .includeConfiguration(config.getIncludeConfiguration()) .lastBackup(lastBackup) .nextScheduledBackup(calculateNextBackup(config.getFrequency(), config.getBackupTime())) .totalBackups((int) totalBackups) .totalSizeBytes(totalSizeBytes) .totalSizeFormatted(formatBytes(totalSizeBytes)) .build(); } private BackupResponse mapToResponse(BackupRecord record) { return BackupResponse.builder() .id(record.getId()) .name(record.getName()) .description(record.getDescription()) .type(record.getType()) .sizeBytes(record.getSizeBytes() != null ? record.getSizeBytes() : 0L) .sizeFormatted(formatBytes(record.getSizeBytes() != null ? record.getSizeBytes() : 0L)) .status(record.getStatus()) .createdAt(record.getDateCreation()) .completedAt(record.getCompletedAt()) .createdBy(record.getCreatedBy()) .includesDatabase(record.getIncludesDatabase()) .includesFiles(record.getIncludesFiles()) .includesConfiguration(record.getIncludesConfiguration()) .filePath(record.getFilePath()) .build(); } private LocalDateTime calculateNextBackup(String frequency, String backupTime) { LocalTime time = backupTime != null ? LocalTime.parse(backupTime) : LocalTime.of(2, 0); LocalDateTime next = LocalDateTime.now().with(time); if (frequency == null) frequency = "DAILY"; switch (frequency) { case "HOURLY": return LocalDateTime.now().plusHours(1); case "WEEKLY": if (next.isBefore(LocalDateTime.now())) next = next.plusWeeks(1); return next; default: // DAILY if (next.isBefore(LocalDateTime.now())) next = next.plusDays(1); return next; } } private String formatBytes(long bytes) { if (bytes <= 0) return "0 B"; if (bytes < 1024) return bytes + " B"; int exp = (int) (Math.log(bytes) / Math.log(1024)); char pre = "KMGTPE".charAt(exp - 1); return String.format("%.1f %sB", bytes / Math.pow(1024, exp), pre); } }