Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ public void started() {
log.debug("creating backup {} into {}", name, targetFile.getFileName().toString());
TarGzPacker.createTarGz(ServerUtil.getHome(), targetFile.toFile(), sources);

if (backup.isProcessOnlyOnChange()) {
Path checksumFile = targetPath.resolve(name + ".sha256");
String newChecksum = BackupUtil.calculateSHA256(targetFile);

if (Files.exists(checksumFile)) {
String oldChecksum = Files.readString(checksumFile);
if (oldChecksum.equals(newChecksum)) {
log.debug("backup {} has not changed, skipping post-processing and deleting new backup.", name);
Files.delete(targetFile);
return; // Skip post-processing
}
}
Path tempChecksumFile = Files.createTempFile(targetPath, name, ".sha256.tmp");
Files.writeString(tempChecksumFile, newChecksum);
Files.move(tempChecksumFile, checksumFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
}

var hookSystem = getContext().get(InjectorFeature.class).injector().getInstance(HookSystem.class);
hookSystem.execute("module/backup/postprocess", Map.of(
"file", targetFile.toString(),
Expand Down
90 changes: 31 additions & 59 deletions src/main/java/com/condation/cms/modules/backup/BackupUtil.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
package com.condation.cms.modules.backup;

/*-
* #%L
* backup-module
Expand All @@ -21,65 +19,39 @@
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #L%
*/
package com.condation.cms.modules.backup;

import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;

import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class BackupUtil {

/**
* Creates a tar.gz backup of the specified path while ignoring the temp/
* folder.
*
* @param sourceDir the path to the source directory
* @param targetFile the target file for the resulting tar.gz
* @throws IOException if any read or write errors occur
*/
public static void createTarGzBackup(Path sourceDir, Path targetFile) throws IOException {
if (!Files.isDirectory(sourceDir)) {
throw new IllegalArgumentException("sourceDir muss ein Ordner sein");
}

try (OutputStream fOut = Files.newOutputStream(targetFile); BufferedOutputStream buffOut = new BufferedOutputStream(fOut); GzipCompressorOutputStream gzOut = new GzipCompressorOutputStream(buffOut); TarArchiveOutputStream tarOut = new TarArchiveOutputStream(gzOut)) {

tarOut.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);

Files.walkFileTree(sourceDir, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
// temp/ Ordner ignorieren
if (dir.getFileName().toString().equalsIgnoreCase("temp")) {
return FileVisitResult.SKIP_SUBTREE;
}
if (!sourceDir.equals(dir)) {
// Relativer Pfad
Path relativePath = sourceDir.relativize(dir);
TarArchiveEntry entry = new TarArchiveEntry(dir.toFile(), relativePath.toString() + "/");
tarOut.putArchiveEntry(entry);
tarOut.closeArchiveEntry();
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
// Datei schreiben
Path relativePath = sourceDir.relativize(file);
TarArchiveEntry entry = new TarArchiveEntry(file.toFile(), relativePath.toString());
entry.setSize(Files.size(file));
tarOut.putArchiveEntry(entry);
Files.copy(file, tarOut);
tarOut.closeArchiveEntry();
return FileVisitResult.CONTINUE;
}
});

tarOut.finish();
}
}
public static String calculateSHA256(Path file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream is = Files.newInputStream(file)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
}
byte[] encodedhash = digest.digest();
return bytesToHex(encodedhash);
}

private static String bytesToHex(byte[] hash) {
StringBuilder hexString = new StringBuilder(2 * hash.length);
for (int i = 0; i < hash.length; i++) {
String hex = Integer.toHexString(0xff & hash[i]);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static class Backup {

private String name;
private boolean enabled = false;
private boolean processOnlyOnChange = false;
private List<String> include_files;
private List<String> include_dirs;
private List<PostProcessing> post_processing;
Expand Down
66 changes: 25 additions & 41 deletions src/main/java/com/condation/cms/modules/backup/TarGzPacker.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,66 +22,50 @@
* #L%
*/

import com.condation.cms.api.utils.ServerUtil;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
import org.apache.commons.io.IOUtils;
import org.apache.commons.compress.utils.IOUtils;

/**
*
* @author thmar
*/
public class TarGzPacker {

public static void createTarGz(Path root, File output, List<Path> sources) throws IOException {
Path rootPath = root.toAbsolutePath().normalize();

try (FileOutputStream fos = new FileOutputStream(output);
BufferedOutputStream bos = new BufferedOutputStream(fos);
GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(bos);
public static void createTarGz(Path basePath, File outputFile, List<Path> sources) throws IOException {
try (FileOutputStream fos = new FileOutputStream(outputFile);
GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(fos);
TarArchiveOutputStream taos = new TarArchiveOutputStream(gzos)) {

taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);

for (Path source : sources) {
Path sourcePath = source.toAbsolutePath().normalize();
if (!sourcePath.startsWith(rootPath)) {
throw new IllegalArgumentException("source directory not inside server home: " + source);
for (Path source : sources) {
if (Files.isDirectory(source)) {
try (Stream<Path> stream = Files.walk(source)) {
stream.filter(p -> !Files.isDirectory(p)).forEach(p -> {
try {
addFileToTar(basePath, p, taos);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
} else {
addFileToTar(basePath, source, taos);
}
addFileToTarGz(taos, sourcePath, rootPath);
}
}
}

private static void addFileToTarGz(TarArchiveOutputStream taos, Path path, Path root) throws IOException {
Path relativePath = root.relativize(path);
String entryName = relativePath.toString().replace("\\", "/");

TarArchiveEntry entry = new TarArchiveEntry(path.toFile(), entryName);
private static void addFileToTar(Path basePath, Path file, TarArchiveOutputStream taos) throws IOException {
String relativePath = basePath.relativize(file).toString();
TarArchiveEntry entry = new TarArchiveEntry(file.toFile(), relativePath);
taos.putArchiveEntry(entry);

if (Files.isRegularFile(path)) {
try (InputStream is = Files.newInputStream(path)) {
IOUtils.copy(is, taos);
}
taos.closeArchiveEntry();
} else if (Files.isDirectory(path)) {
taos.closeArchiveEntry();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(path)) {
for (Path child : stream) {
addFileToTarGz(taos, child, root);
}
}
try (FileInputStream fis = new FileInputStream(file.toFile())) {
IOUtils.copy(fis, taos);
}
taos.closeArchiveEntry();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package com.condation.cms.modules.backup;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.condation.cms.api.extensions.server.ServerLifecycleExtensionPoint;
import com.condation.cms.api.feature.features.InjectorFeature;
import com.condation.cms.api.hooks.HookSystem;
import com.condation.cms.api.scheduler.CronJobScheduler;
import com.condation.cms.api.scheduler.ScheduledTask;
import com.condation.cms.api.utils.PathUtil;
import com.condation.cms.api.utils.ServerUtil;
import com.google.inject.Injector;
import com.google.inject.Key;
import com.google.inject.name.Names;

import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;

public class BackupLifecycleExtensionTest {

private BackupLifecycleExtension extension;
private CronJobScheduler scheduler;
private HookSystem hookSystem;
private Path tempDir;
private Context context;
private InjectorFeature injectorFeature;
private Injector injector;

@BeforeEach
public void setUp(@TempDir Path tempDir) throws Exception {
this.tempDir = tempDir;

// Mocks
scheduler = mock(CronJobScheduler.class);
hookSystem = mock(HookSystem.class);
context = mock(ServerLifecycleExtensionPoint.Context.class);
injectorFeature = mock(InjectorFeature.class);
injector = mock(Injector.class);

// Stubbing
when(context.get(InjectorFeature.class)).thenReturn(injectorFeature);
when(injectorFeature.injector()).thenReturn(injector);
when(injector.getInstance(Key.get(CronJobScheduler.class, Names.named("server")))).thenReturn(scheduler);
when(injector.getInstance(HookSystem.class)).thenReturn(hookSystem);

// Class under test
extension = new BackupLifecycleExtension();

// Inject mock context using reflection
Field contextField = ServerLifecycleExtensionPoint.class.getDeclaredField("context");
contextField.setAccessible(true);
contextField.set(extension, context);
}

private void runBackup(Configuration config) {
try (MockedStatic<ConfigLoader> mockedConfigLoader = mockStatic(ConfigLoader.class);
MockedStatic<ServerUtil> mockedServerUtil = mockStatic(ServerUtil.class);
MockedStatic<PathUtil> mockedPathUtil = mockStatic(PathUtil.class)) {

mockedConfigLoader.when(ConfigLoader::load).thenReturn(Optional.of(config));
mockedServerUtil.when(ServerUtil::getHome).thenReturn(tempDir);
mockedPathUtil.when(() -> PathUtil.isChild(any(), any())).thenReturn(true);

ArgumentCaptor<ScheduledTask> taskCaptor = ArgumentCaptor.forClass(ScheduledTask.class);
extension.started();
verify(scheduler).schedule(anyString(), anyString(), taskCaptor.capture());
taskCaptor.getValue().execute(null);
}
}

@Test
public void testBackupSkipsPostProcessingWhenNoChanges() throws IOException, NoSuchAlgorithmException {
// Arrange
Path sourceFile = Files.createFile(tempDir.resolve("source.txt"));
Files.writeString(sourceFile, "This is a test file.");

Path tempBackupFile = tempDir.resolve("temp.tar.gz");
TarGzPacker.createTarGz(tempDir, tempBackupFile.toFile(), Collections.singletonList(sourceFile));
String checksum = BackupUtil.calculateSHA256(tempBackupFile);
Files.delete(tempBackupFile);

Path checksumFile = tempDir.resolve("testBackup.sha256");
Files.writeString(checksumFile, checksum);

Configuration.Backup backupConfig = new Configuration.Backup();
backupConfig.setName("testBackup");
backupConfig.setEnabled(true);
backupConfig.setProcessOnlyOnChange(true);
backupConfig.setTarget(tempDir.toString());
backupConfig.setCron("0 0 * * *");
backupConfig.setInclude_files(Collections.singletonList(sourceFile.toString()));

Configuration config = new Configuration();
config.setBackups(Collections.singletonList(backupConfig));

// Act
runBackup(config);

// Assert
verify(hookSystem, never()).execute(anyString(), any(Map.class));
try (Stream<Path> files = Files.list(tempDir)) {
long backupFileCount = files.filter(p -> p.toString().endsWith(".tar.gz")).count();
assertEquals(0, backupFileCount, "No backup file should exist as it should be deleted.");
}
}

@Test
public void testBackupRunsPostProcessingWhenChanges() throws IOException, NoSuchAlgorithmException {
// Arrange
Path sourceFile = Files.createFile(tempDir.resolve("source.txt"));
Files.writeString(sourceFile, "This is a test file.");

Path tempBackupFile = tempDir.resolve("temp.tar.gz");
TarGzPacker.createTarGz(tempDir, tempBackupFile.toFile(), Collections.singletonList(sourceFile));
String checksum = BackupUtil.calculateSHA256(tempBackupFile);
Files.delete(tempBackupFile);

Path checksumFile = tempDir.resolve("testBackup.sha256");
Files.writeString(checksumFile, checksum);

// Change the source file so the new backup has a different checksum
Files.writeString(sourceFile, "This is a modified test file.");

Configuration.Backup backupConfig = new Configuration.Backup();
backupConfig.setName("testBackup");
backupConfig.setEnabled(true);
backupConfig.setProcessOnlyOnChange(true);
backupConfig.setTarget(tempDir.toString());
backupConfig.setCron("0 0 * * *");
backupConfig.setInclude_files(Collections.singletonList(sourceFile.toString()));

Configuration config = new Configuration();
config.setBackups(Collections.singletonList(backupConfig));

// Act
runBackup(config);

// Assert
verify(hookSystem, times(1)).execute(eq("module/backup/postprocess"), any(Map.class));
try (Stream<Path> files = Files.list(tempDir)) {
long backupFileCount = files.filter(p -> p.toString().endsWith(".tar.gz")).count();
assertEquals(1, backupFileCount, "A new backup file should exist.");
}
assertTrue(Files.exists(checksumFile));
}
}