diff --git a/.gitignore b/.gitignore index 5629f08..fc69275 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ out/ ### VS Code ### */.vscode/ + +file-barj.log diff --git a/README.md b/README.md index 379c357..09e1c90 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,14 @@ File BaRJ comes with the following features - Duplicate handling (storing duplicates of the same file only once) - Deletes left-over files from the restore directory (if they had been in scope for the backup) - Merge previous backup increments +- Delete selected backup increments ### Planned features -- Delete selected backup increments - UI for convenient configuration +- Rotate encryption keys + - Shallow (only rotate KEK) + - Deep (rotate DEKs as well) ## Modules diff --git a/file-barj-core/README.md b/file-barj-core/README.md index 4fc9d1f..4b319b7 100644 --- a/file-barj-core/README.md +++ b/file-barj-core/README.md @@ -113,6 +113,20 @@ controller.inspectIncrements(System.out); controller.inspectContent(Long.MAX_VALUE, outputFile); ``` +### Deleting the increments of an archive + +```java +//configuring the deletion job +final var backupDir = Path.of("/backup/directory"); +final var outputFile = Path.of("/backup/directory"); +final var controller = new IncrementDeletionController(backupDir, "file-prefix", null); + +//Delete all backup increments: +// - starting with the one created at 123456 +// - until (exclusive) the next full backup +controller.deleteIncrementsUntilNextFullBackupAfter(123456L); +``` + ## Further reading Please read more about the BaRJ backup jobs [here](https://github.com/nagyesta/file-barj/wiki/Backup-job-configuration-tips). diff --git a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManager.java b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManager.java index c6c1815..3408094 100644 --- a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManager.java +++ b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManager.java @@ -110,5 +110,17 @@ void validate( * @param job the job configuration * @return the manifests which can act as previous increments of the provided job */ - SortedMap loadPreviousManifestsForBackup(BackupJobConfiguration job); + SortedMap loadPreviousManifestsForBackup( + @NonNull BackupJobConfiguration job); + + /** + * Deletes all files of the backup increment represented by the provided manifest from the + * backup directory. + * + * @param backupDirectory the backup directory + * @param manifest the manifest + */ + void deleteIncrement( + @NonNull Path backupDirectory, + @NonNull BackupIncrementManifest manifest); } diff --git a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImpl.java b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImpl.java index 3a63a71..a0f07ca 100644 --- a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImpl.java +++ b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImpl.java @@ -33,6 +33,8 @@ */ @Slf4j public class ManifestManagerImpl implements ManifestManager { + private static final String HISTORY_FOLDER = ".history"; + private static final String MANIFEST_JSON_GZ = ".manifest.json.gz"; private final ObjectMapper mapper = new ObjectMapper(); @Override @@ -76,10 +78,10 @@ public void persist( private void doPersist( @NotNull final BackupIncrementManifest manifest, @NotNull final File backupDestination) { - final var backupHistoryDir = new File(backupDestination, ".history"); + final var backupHistoryDir = new File(backupDestination, HISTORY_FOLDER); //noinspection ResultOfMethodCallIgnored backupHistoryDir.mkdirs(); - final var plainManifestFile = new File(backupHistoryDir, manifest.getFileNamePrefix() + ".manifest.json.gz"); + final var plainManifestFile = new File(backupHistoryDir, manifest.getFileNamePrefix() + MANIFEST_JSON_GZ); try (var fileStream = new FileOutputStream(plainManifestFile); var bufferedStream = new BufferedOutputStream(fileStream); var gzipStream = new GZIPOutputStream(bufferedStream); @@ -140,15 +142,16 @@ public SortedMap loadAll( } @Override - public SortedMap loadPreviousManifestsForBackup(final BackupJobConfiguration job) { - final var historyFolder = job.getDestinationDirectory().resolve(".history"); + public SortedMap loadPreviousManifestsForBackup( + @NonNull final BackupJobConfiguration job) { + final var historyFolder = job.getDestinationDirectory().resolve(HISTORY_FOLDER); if (!Files.exists(historyFolder)) { return Collections.emptySortedMap(); } try (var pathStream = Files.list(historyFolder)) { final var manifestFiles = pathStream .filter(path -> path.getFileName().toString().startsWith(job.getFileNamePrefix())) - .filter(path -> path.getFileName().toString().endsWith(".manifest.json.gz")) + .filter(path -> path.getFileName().toString().endsWith(MANIFEST_JSON_GZ)) .sorted(Comparator.comparing(Path::getFileName).reversed()) .toList(); final var manifests = loadManifests(manifestFiles, null, Long.MAX_VALUE); @@ -202,6 +205,15 @@ public void validate( //TODO: implement validation } + @Override + public void deleteIncrement( + @NonNull final Path backupDirectory, + @NonNull final BackupIncrementManifest manifest) { + final var fileNamePrefix = manifest.getFileNamePrefix(); + deleteManifestFromHistoryIfExists(backupDirectory, fileNamePrefix); + deleteManifestAndArchiveFilesFromBackupDirectory(backupDirectory, fileNamePrefix); + } + @NotNull private SortedMap loadManifests( @NotNull final List manifestFiles, @@ -342,4 +354,45 @@ private SortedMap> mergeEncryptionKeys( .forEach(keys::putAll); return keys; } + + private void deleteManifestAndArchiveFilesFromBackupDirectory( + @NotNull final Path backupDirectory, @NotNull final String fileNamePrefix) { + final var patterns = Set.of( + "^" + fileNamePrefix + "\\.[0-9]{5}\\.cargo$", + "^" + fileNamePrefix + "\\.manifest\\.cargo$", + "^" + fileNamePrefix + "\\.index\\.cargo$" + ); + try (var list = Files.list(backupDirectory)) { + final var toDelete = new ArrayList(); + list.filter(path -> patterns.stream().anyMatch(pattern -> path.getFileName().toString().matches(pattern))) + .forEach(toDelete::add); + for (final var path : toDelete) { + log.info("Deleting obsolete backup file: {}", path); + try { + Files.delete(path); + } catch (final IOException e) { + log.warn("Unable to delete file! Will attempt to delete it on exit.", e); + if (Files.exists(path)) { + path.toFile().deleteOnExit(); + } + } + } + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + private void deleteManifestFromHistoryIfExists( + @NotNull final Path backupDirectory, @NotNull final String fileNamePrefix) { + final var fromHistory = backupDirectory.resolve(HISTORY_FOLDER) + .resolve(fileNamePrefix + MANIFEST_JSON_GZ); + try { + if (Files.exists(fromHistory)) { + log.info("Deleting obsolete file from history: {}", fromHistory); + Files.delete(fromHistory); + } + } catch (final IOException e) { + log.error("Could not delete manifest file from history folder: {}", fromHistory, e); + } + } } diff --git a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/delete/IncrementDeletionController.java b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/delete/IncrementDeletionController.java new file mode 100644 index 0000000..74fe534 --- /dev/null +++ b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/delete/IncrementDeletionController.java @@ -0,0 +1,67 @@ +package com.github.nagyesta.filebarj.core.delete; + +import com.github.nagyesta.filebarj.core.common.ManifestManager; +import com.github.nagyesta.filebarj.core.common.ManifestManagerImpl; +import com.github.nagyesta.filebarj.core.model.BackupIncrementManifest; +import com.github.nagyesta.filebarj.core.model.enums.BackupType; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; +import java.security.PrivateKey; +import java.util.Comparator; +import java.util.SortedMap; + +/** + * Controller for the backup increment deletion task. + */ +@Slf4j +public class IncrementDeletionController { + + private final SortedMap manifests; + private final @NonNull Path backupDirectory; + private final ManifestManager manifestManager; + + /** + * Creates a new instance and initializes it for the specified job. + * + * @param backupDirectory the directory where the backup files are located + * @param fileNamePrefix the prefix of the backup file names + * @param kek The key encryption key we want to use to decrypt the files (optional). + * If null, no decryption will be performed. + */ + public IncrementDeletionController( + @NonNull final Path backupDirectory, + @NonNull final String fileNamePrefix, + @Nullable final PrivateKey kek) { + this.manifestManager = new ManifestManagerImpl(); + this.backupDirectory = backupDirectory; + this.manifests = this.manifestManager.loadAll(this.backupDirectory, fileNamePrefix, kek); + } + + /** + * Deletes the incremental backups which were created after the specified time until the next + * full backup. + * + * @param startingWithEpochSeconds the start time of the first deleted increment + */ + public void deleteIncrementsUntilNextFullBackupAfter(final long startingWithEpochSeconds) { + final var incrementsStartingWithThreshold = this.manifests.values().stream() + .sorted(Comparator.comparing(BackupIncrementManifest::getStartTimeUtcEpochSeconds)) + .filter(manifest -> manifest.getStartTimeUtcEpochSeconds() >= startingWithEpochSeconds) + .toList(); + if (incrementsStartingWithThreshold.isEmpty()) { + throw new IllegalArgumentException("No backups found after: " + startingWithEpochSeconds); + } + if (incrementsStartingWithThreshold.get(0).getStartTimeUtcEpochSeconds() != startingWithEpochSeconds) { + throw new IllegalArgumentException("Unable to find backup which started at: " + startingWithEpochSeconds); + } + for (final var current : incrementsStartingWithThreshold) { + if (current.getStartTimeUtcEpochSeconds() > startingWithEpochSeconds && current.getBackupType() == BackupType.FULL) { + break; + } + manifestManager.deleteIncrement(backupDirectory, current); + } + } +} diff --git a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/merge/MergeController.java b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/merge/MergeController.java index b88f16b..54f1d89 100644 --- a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/merge/MergeController.java +++ b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/merge/MergeController.java @@ -16,7 +16,6 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.security.PrivateKey; import java.util.*; @@ -85,9 +84,7 @@ public BackupIncrementManifest execute(final boolean deleteObsoleteFiles) { if (deleteObsoleteFiles) { log.info("Deleting obsolete files from backup directory: {}", backupDirectory); selectedManifests.values().forEach(manifest -> { - final var fileNamePrefix = manifest.getFileNamePrefix(); - deleteManifestFromHistoryIfExists(fileNamePrefix); - deleteManifestAndArchiveFilesFromBackupDirectory(fileNamePrefix); + manifestManager.deleteIncrement(backupDirectory, manifest); }); } return result; @@ -96,45 +93,6 @@ public BackupIncrementManifest execute(final boolean deleteObsoleteFiles) { } } - private void deleteManifestAndArchiveFilesFromBackupDirectory(@NotNull final String fileNamePrefix) { - final var patterns = Set.of( - "^" + fileNamePrefix + "\\.[0-9]{5}\\.cargo$", - "^" + fileNamePrefix + "\\.manifest\\.cargo$", - "^" + fileNamePrefix + "\\.index\\.cargo$" - ); - try (var list = Files.list(backupDirectory)) { - final var toDelete = new ArrayList(); - list.filter(path -> patterns.stream().anyMatch(pattern -> path.getFileName().toString().matches(pattern))) - .forEach(toDelete::add); - for (final var path : toDelete) { - log.info("Deleting obsolete file: {}", path); - try { - Files.delete(path); - } catch (final IOException e) { - log.warn("Unable to delete file! Will attempt to delete it on exit.", e); - if (Files.exists(path)) { - path.toFile().deleteOnExit(); - } - } - } - } catch (final IOException e) { - throw new RuntimeException(e); - } - } - - private void deleteManifestFromHistoryIfExists(@NotNull final String fileNamePrefix) { - final var fromHistory = backupDirectory.resolve(".history") - .resolve(fileNamePrefix + ".manifest.json.gz"); - try { - if (Files.exists(fromHistory)) { - log.info("Deleting obsolete file from history: {}", fromHistory); - Files.delete(fromHistory); - } - } catch (final IOException e) { - log.error("Could not delete manifest file from history folder: " + fromHistory, e); - } - } - @NotNull private BackupIncrementManifest mergeBackupContent() { final var lastManifest = manifestsToMerge.get(manifestsToMerge.lastKey()); diff --git a/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImplTest.java b/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImplTest.java index fd85066..eb89a65 100644 --- a/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImplTest.java +++ b/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/common/ManifestManagerImplTest.java @@ -383,4 +383,42 @@ void testMergeForRestoreShouldThrowExceptionWhenCalledWithNull() { //then + exception } + + @SuppressWarnings("DataFlowIssue") + @Test + void testDeleteIncrementShouldThrowExceptionWhenCalledWithNullManifest() { + //given + final var underTest = new ManifestManagerImpl(); + + //when + Assertions.assertThrows(IllegalArgumentException.class, () -> underTest.deleteIncrement(Path.of("destination"), null)); + + //then + exception + } + + @SuppressWarnings("DataFlowIssue") + @Test + void testDeleteIncrementShouldThrowExceptionWhenCalledWithNullDestination() { + //given + final var underTest = new ManifestManagerImpl(); + + //when + Assertions.assertThrows(IllegalArgumentException.class, + () -> underTest.deleteIncrement(null, mock(BackupIncrementManifest.class))); + + //then + exception + } + + @SuppressWarnings("DataFlowIssue") + @Test + void testLoadPreviousManifestsForBackupShouldThrowExceptionWhenCalledWithNull() { + //given + final var underTest = new ManifestManagerImpl(); + + //when + Assertions.assertThrows(IllegalArgumentException.class, + () -> underTest.loadPreviousManifestsForBackup(null)); + + //then + exception + } } diff --git a/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/delete/IncrementDeletionControllerTest.java b/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/delete/IncrementDeletionControllerTest.java new file mode 100644 index 0000000..c10d826 --- /dev/null +++ b/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/delete/IncrementDeletionControllerTest.java @@ -0,0 +1,126 @@ +package com.github.nagyesta.filebarj.core.delete; + +import com.github.nagyesta.filebarj.core.TempFileAwareTest; +import com.github.nagyesta.filebarj.core.backup.pipeline.BackupController; +import com.github.nagyesta.filebarj.core.config.BackupJobConfiguration; +import com.github.nagyesta.filebarj.core.config.BackupSource; +import com.github.nagyesta.filebarj.core.config.enums.CompressionAlgorithm; +import com.github.nagyesta.filebarj.core.config.enums.DuplicateHandlingStrategy; +import com.github.nagyesta.filebarj.core.config.enums.HashAlgorithm; +import com.github.nagyesta.filebarj.core.model.BackupPath; +import com.github.nagyesta.filebarj.core.model.enums.BackupType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class IncrementDeletionControllerTest extends TempFileAwareTest { + private static final long ONE_SECOND = 1000L; + + @SuppressWarnings({"checkstyle:MagicNumber", "MagicNumber"}) + public Stream validParameterProvider() { + return Stream.builder() + .add(Arguments.of(1, 0, 0, 0)) + .add(Arguments.of(2, 1, 3, 1)) + .add(Arguments.of(5, 4, 12, 4)) + .add(Arguments.of(5, 0, 0, 0)) + .add(Arguments.of(5, 1, 3, 1)) + .build(); + } + + @SuppressWarnings("DataFlowIssue") + @Test + void testConstructorShouldThrowExceptionWhenCalledWithNullBackupDirectory() { + //given + final var fileNamePrefix = "prefix"; + + //when + assertThrows(IllegalArgumentException.class, () -> new IncrementDeletionController(null, fileNamePrefix, null)); + + //then + exception + } + + @SuppressWarnings("DataFlowIssue") + @Test + void testConstructorShouldThrowExceptionWhenCalledWithNullPrefix() { + //given + final var backupDirectory = testDataRoot; + + //when + assertThrows(IllegalArgumentException.class, () -> new IncrementDeletionController(backupDirectory, null, null)); + + //then + exception + } + + @DisabledOnOs(OS.WINDOWS) + @ParameterizedTest + @MethodSource("validParameterProvider") + void testDeleteIncrementsShouldReturnSummariesWhenCalledWithStream( + final int backups, final int skip, final long expectedBackupFiles, final long expectedHistoryFiles) + throws IOException, InterruptedException { + //given + final var originalDirectory = testDataRoot.resolve("original"); + final var backupDirectory = testDataRoot.resolve("backup"); + Files.createDirectories(originalDirectory); + Files.writeString(originalDirectory.resolve("file1.txt"), "content"); + final var prefix = "prefix"; + for (var i = 0; i < backups; i++) { + doBackup(backupDirectory, originalDirectory, prefix); + Thread.sleep(ONE_SECOND); + } + final var underTest = new IncrementDeletionController(backupDirectory, prefix, null); + final var firstBackupStarted = Arrays.stream(Objects.requireNonNull(backupDirectory.toFile().list())) + .filter(child -> child.startsWith(prefix) && child.endsWith(".manifest.cargo")) + .sorted() + .skip(skip) + .findFirst() + .map(child -> child.replaceFirst(Pattern.quote(prefix + "-"), "").replaceFirst("\\..+$", "")) + .map(Long::parseLong) + .orElseThrow(); + + //when + underTest.deleteIncrementsUntilNextFullBackupAfter(firstBackupStarted); + Thread.sleep(ONE_SECOND); + + //then + final var matchingBackupFiles = Arrays.stream(Objects.requireNonNull(backupDirectory.toFile().list())) + .filter(child -> child.startsWith(prefix)) + .count(); + final var matchingHistoryFiles = Arrays.stream(Objects.requireNonNull(backupDirectory.resolve(".history").toFile().list())) + .filter(child -> child.startsWith(prefix)) + .count(); + Assertions.assertEquals(expectedHistoryFiles, matchingHistoryFiles); + Assertions.assertEquals(expectedBackupFiles, matchingBackupFiles); + } + + @SuppressWarnings("SameParameterValue") + private static void doBackup( + final Path backupDirectory, + final Path originalDirectory, + final String prefix) { + final var configuration = BackupJobConfiguration.builder() + .destinationDirectory(backupDirectory) + .sources(Set.of(BackupSource.builder().path(BackupPath.of(originalDirectory)).build())) + .backupType(BackupType.INCREMENTAL) + .hashAlgorithm(HashAlgorithm.SHA256) + .fileNamePrefix(prefix) + .compression(CompressionAlgorithm.NONE) + .duplicateStrategy(DuplicateHandlingStrategy.KEEP_ONE_PER_BACKUP) + .build(); + new BackupController(configuration, false).execute(1); + } +} diff --git a/file-barj-job/README.md b/file-barj-job/README.md index 63d4f90..b9c1a4f 100644 --- a/file-barj-job/README.md +++ b/file-barj-job/README.md @@ -120,6 +120,26 @@ java -jar build/libs/file-barj-job.jar \ --at-epoch-seconds 123456 ``` +### Deleting the increments of a backup + +This task allows the deletion of those backup increments which became obsolete. In order to keep +only working backups, this will remove all increments starting with the one created at the selected +epoch seconds time and ending before the next full backup (or deleting all if no full backup found). +In the example below, we want to delete the increment started at 123456 (epoch seconds) amd every +subsequent incremental backup until the next full backup. + +Execute the following command (assuming that your executable is named accordingly). + +```commandline +java -jar build/libs/file-barj-job.jar \ + --delete \ + --backup-source /backup/directory/path \ + --prefix backup-job-file-prefix \ + --key-store keys.p12 \ + --key-alias alias \ + --from-epoch-seconds 123456 +``` + ## Further reading Please read more about configuring the BaRJ backup jobs [here](https://github.com/nagyesta/file-barj/wiki/Backup-job-configuration-tips). diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/Controller.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/Controller.java index 537c33e..3e891ce 100644 --- a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/Controller.java +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/Controller.java @@ -6,6 +6,7 @@ import com.github.nagyesta.filebarj.core.config.RestoreTarget; import com.github.nagyesta.filebarj.core.config.RestoreTargets; import com.github.nagyesta.filebarj.core.config.RestoreTask; +import com.github.nagyesta.filebarj.core.delete.IncrementDeletionController; import com.github.nagyesta.filebarj.core.inspect.pipeline.IncrementInspectionController; import com.github.nagyesta.filebarj.core.merge.MergeController; import com.github.nagyesta.filebarj.core.restore.pipeline.RestoreController; @@ -91,6 +92,12 @@ public void run() throws Exception { .toArray(String[]::new), console).getResult(); doInspectIncrements(inspectIncrementsProperties); break; + case DELETE_INCREMENTS: + final var deleteIncrementsProperties = new CliDeleteIncrementsParser(Arrays.stream(args) + .skip(1) + .toArray(String[]::new), console).getResult(); + doDeleteIncrements(deleteIncrementsProperties); + break; default: throw new IllegalArgumentException("No task found."); } @@ -116,7 +123,18 @@ protected void doInspectIncrements(final InspectIncrementsProperties properties) .inspectIncrements(System.out); final var endTimeMillis = System.currentTimeMillis(); final var durationMillis = (endTimeMillis - startTimeMillis); - log.info("Increment increments inspection operation completed. Total time: {}", toProcessSummary(durationMillis)); + log.info("Backup increments inspection operation completed. Total time: {}", toProcessSummary(durationMillis)); + } + + protected void doDeleteIncrements(final DeleteIncrementsProperties properties) { + final var kek = getPrivateKey(properties.getKeyProperties()); + final var startTimeMillis = System.currentTimeMillis(); + log.info("Bootstrapping delete increments operation..."); + new IncrementDeletionController(properties.getBackupSource(), properties.getPrefix(), kek) + .deleteIncrementsUntilNextFullBackupAfter(properties.getAfterEpochSeconds()); + final var endTimeMillis = System.currentTimeMillis(); + final var durationMillis = (endTimeMillis - startTimeMillis); + log.info("Increment deletion operation completed. Total time: {}", toProcessSummary(durationMillis)); } protected void doGenerateKey(final KeyStoreProperties properties) { diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliDeleteIncrementsParser.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliDeleteIncrementsParser.java new file mode 100644 index 0000000..4a2fb13 --- /dev/null +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliDeleteIncrementsParser.java @@ -0,0 +1,51 @@ +package com.github.nagyesta.filebarj.job.cli; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; + +import java.io.Console; +import java.nio.file.Path; + +/** + * Parser class for the command line arguments of the version deletion task. + */ +@Slf4j +public class CliDeleteIncrementsParser extends CliICommonBackupFileParser { + + private static final String FROM_EPOCH_SECONDS = "from-epoch-seconds"; + + /** + * Creates a new {@link CliDeleteIncrementsParser} instance and sets the input arguments. + * + * @param args the command line arguments + * @param console the console we should use for password input + */ + public CliDeleteIncrementsParser(final String[] args, final Console console) { + super(Task.DELETE_INCREMENTS, args, commandLine -> { + final var prefix = commandLine.getOptionValue(PREFIX); + final var keyProperties = parseKeyProperties(console, commandLine); + final var backupSource = Path.of(commandLine.getOptionValue(BACKUP_SOURCE)).toAbsolutePath(); + final var fromTime = Long.parseLong(commandLine.getOptionValue(FROM_EPOCH_SECONDS)); + return DeleteIncrementsProperties.builder() + .keyProperties(keyProperties) + .backupSource(backupSource) + .prefix(prefix) + .afterEpochSeconds(fromTime) + .build(); + }); + } + + @Override + protected Options createOptions() { + return super.createOptions() + .addOption(Option.builder() + .longOpt(FROM_EPOCH_SECONDS) + .numberOfArgs(1) + .argName("epoch_seconds") + .required(true) + .type(Long.class) + .desc("The date and time using UTC epoch seconds identifying the first increment we want to delete.") + .build()); + } +} diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliTaskParser.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliTaskParser.java index 816c0d9..de1c564 100644 --- a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliTaskParser.java +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliTaskParser.java @@ -44,7 +44,10 @@ protected Options createOptions() { .desc("Inspects the available backup increments of a backup.").build()) .addOption(Option.builder() .longOpt(Task.INSPECT_INCREMENTS.getCommand()) - .desc("Inspects the contents of a backup increment.").build()); + .desc("Inspects the contents of a backup increment.").build()) + .addOption(Option.builder() + .longOpt(Task.DELETE_INCREMENTS.getCommand()) + .desc("Deletes the backup increments starting from a given time until the next full backup.").build()); group.setRequired(true); return new Options().addOptionGroup(group); } diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/DeleteIncrementsProperties.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/DeleteIncrementsProperties.java new file mode 100644 index 0000000..643a841 --- /dev/null +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/DeleteIncrementsProperties.java @@ -0,0 +1,17 @@ +package com.github.nagyesta.filebarj.job.cli; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * The parsed command line arguments of a version deletion task. + */ +@Data +@SuperBuilder +@EqualsAndHashCode(callSuper = true) +public class DeleteIncrementsProperties extends BackupFileProperties { + + private final long afterEpochSeconds; +} + diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/Task.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/Task.java index 03493a6..5cd755f 100644 --- a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/Task.java +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/Task.java @@ -31,7 +31,11 @@ public enum Task { /** * Listing the contents of a backup increment. */ - INSPECT_CONTENT("inspect-content"); + INSPECT_CONTENT("inspect-content"), + /** + * Deleting the increments of a backup. + */ + DELETE_INCREMENTS("delete"); private final String command; diff --git a/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerIntegrationTest.java b/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerIntegrationTest.java index 61c7a9b..81a64e7 100644 --- a/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerIntegrationTest.java +++ b/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerIntegrationTest.java @@ -188,5 +188,23 @@ void testEndToEndFlowShouldWorkWhenCalledWithValidParameters() throws Exception "Incremental backup manifest should not exist"); Assertions.assertTrue(Files.exists(backupDirectory.resolve(prefix + "-" + start + "-" + end + ".manifest.cargo")), "Merged backup manifest should exist"); + + //given we inspect the versions + final var deleteIncrementsArgs = new String[]{ + "--delete", + "--backup-source", backupDirectory.toString(), + "--prefix", prefix, + "--key-store", keyStore.toString(), + "--key-alias", alias, + "--from-epoch-seconds", end + "" + }; + + //when inspect increments is executed + new Controller(deleteIncrementsArgs, console).run(); + Thread.sleep(A_SECOND); + + //then the merged manifest no longer exists + Assertions.assertFalse(Files.exists(backupDirectory.resolve(prefix + "-" + start + "-" + end + ".manifest.cargo")), + "Merged backup manifest should no longer exist"); } } diff --git a/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerTest.java b/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerTest.java index 8df231f..bf5c562 100644 --- a/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerTest.java +++ b/file-barj-job/src/test/java/com/github/nagyesta/filebarj/job/ControllerTest.java @@ -128,4 +128,30 @@ void testInspectIncrementsShouldBeCalledWhenRequiredRestoreParametersArePassed() //then verify(underTest).doInspectIncrements(any()); } + + @Test + void testDeleteIncrementsShouldBeCalledWhenRequiredRestoreParametersArePassed() throws Exception { + //given + final var prefix = "prefix"; + final var backup = Path.of("backup-dir"); + final var store = Path.of("key-store.p12"); + final var password = new char[]{'a', 'b', 'c'}; + final var args = new String[]{ + "--delete", + "--backup-source", backup.toString(), + "--prefix", prefix, + "--key-store", store.toString(), + "--from-epoch-seconds", Long.MAX_VALUE + "" + }; + final var console = mock(Console.class); + when(console.readPassword(anyString())).thenReturn(password); + final var underTest = spy(new Controller(args, console)); + doNothing().when(underTest).doDeleteIncrements(any()); + + //when + underTest.run(); + + //then + verify(underTest).doDeleteIncrements(any()); + } }