- BackupService: DB-persisted metadata (BackupRecord/BackupConfig entities + V16 Flyway migration), real pg_dump execution via ProcessBuilder, soft-delete on deleteBackup, pg_restore manual guidance - OrganisationService: repartitionRegion now queries Adresse entities (was Map.of() stub) - SystemConfigService: in-memory config overrides via AtomicReference (no DB dependency) - SystemMetricsService: null-guard on MemoryMXBean in getSystemStatus() (fixes test NPE) - Souscription workflow: SouscriptionService, SouscriptionResource, FormuleAbonnementRepository, V11 Flyway migration, admin REST clients - Flyway V8-V15: notes membres, types référence, type orga constraint, seed roles, première connexion, Wave checkout URL, Wave telephone column length fix - .gitignore: added uploads/ and .claude/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
403 lines
16 KiB
Java
403 lines
16 KiB
Java
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<BackupResponse> 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 <host> -U <user> -d <dbname> -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);
|
|
}
|
|
}
|