diff --git a/README.md b/README.md index de943cd..f5e90de 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ File BaRJ comes with the following features - Backup archive splitting to configurable chunks - Backup archive integrity checks - Restore/unpack previous backup + - Using latest increment + - Using a selected earlier increment +- Inspect available backup increments +- Inspect content of a backup increment - 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) diff --git a/file-barj-core/README.md b/file-barj-core/README.md index 25901e4..f7d5120 100644 --- a/file-barj-core/README.md +++ b/file-barj-core/README.md @@ -75,7 +75,8 @@ final var restoreTask = RestoreTask.builder() .threads(1) .deleteFilesNotInBackup(false) .build(); -final var restoreController = new RestoreController(Path.of("/tmp/backup"), "test", null); +final var pointInTime = 123456L; +final var restoreController = new RestoreController(Path.of("/tmp/backup"), "test", null, pointInTime); //executing the restore restoreController.execute(restoreTask); diff --git a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreController.java b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreController.java index 2f00fc6..2b98bae 100644 --- a/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreController.java +++ b/file-barj-core/src/main/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreController.java @@ -40,17 +40,32 @@ public class RestoreController { * @param kek The key encryption key we want to use to decrypt the files (optional). * If null, no decryption will be performed. */ - @SuppressWarnings("checkstyle:TodoComment") + public RestoreController( + @NotNull final Path backupDirectory, + @NotNull final String fileNamePrefix, + @Nullable final PrivateKey kek) { + this(backupDirectory, fileNamePrefix, kek, Long.MAX_VALUE); + } + + /** + * 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. + * @param atPointInTime the point in time to restore from (inclusive). + */ public RestoreController( @NonNull final Path backupDirectory, @NonNull final String fileNamePrefix, - @Nullable final PrivateKey kek) { + @Nullable final PrivateKey kek, + final long atPointInTime) { this.kek = kek; this.backupDirectory = backupDirectory; final ManifestManager manifestManager = new ManifestManagerImpl(); - //TODO: allow restoring earlier versions log.info("Loading backup manifests for restore from: {}", backupDirectory); - final var manifests = manifestManager.load(backupDirectory, fileNamePrefix, kek, Long.MAX_VALUE); + final var manifests = manifestManager.load(backupDirectory, fileNamePrefix, kek, atPointInTime); log.info("Merging {} manifests", manifests.size()); manifest = manifestManager.mergeForRestore(manifests); final var filesOfLastManifest = manifest.getFilesOfLastManifest(); diff --git a/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreControllerIntegrationTest.java b/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreControllerIntegrationTest.java index bd4ed50..eefb06f 100644 --- a/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreControllerIntegrationTest.java +++ b/file-barj-core/src/test/java/com/github/nagyesta/filebarj/core/restore/pipeline/RestoreControllerIntegrationTest.java @@ -25,6 +25,7 @@ import java.nio.file.Path; import java.security.PrivateKey; import java.security.PublicKey; +import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.Set; @@ -395,17 +396,6 @@ void testExecuteShouldRestoreFilesToDestinationWhenExecutedWithIncrementalBackup new BackupController(configuration, true).execute(1); - final var restoreFullBackup = new RestoreController( - backupDir, configuration.getFileNamePrefix(), decryptionKey); - final var restoreTargets = new RestoreTargets(Set.of(new RestoreTarget(sourceDir, restoreDir))); - final var restoreTask = RestoreTask.builder() - .restoreTargets(restoreTargets) - .threads(threads) - .dryRun(false) - .deleteFilesNotInBackup(deleteLeftOver) - .build(); - restoreFullBackup.execute(restoreTask); - Files.delete(deleted); final var expectedChangedContent = "changed content"; Files.writeString(changed, expectedChangedContent); @@ -419,8 +409,24 @@ void testExecuteShouldRestoreFilesToDestinationWhenExecutedWithIncrementalBackup final var addedLinkExternal = sourceDir.resolve("folder/added-external.png"); Files.createSymbolicLink(addedLinkExternal, externalLinkTarget); + final var fullBackupTime = Instant.now().getEpochSecond(); Thread.sleep(A_SECOND); new BackupController(configuration, false).execute(1); + //create restore controller to read full backup increment + final var restoreFullBackup = new RestoreController( + backupDir, configuration.getFileNamePrefix(), decryptionKey, fullBackupTime); + final var restoreTargets = new RestoreTargets(Set.of(new RestoreTarget(sourceDir, restoreDir))); + final var restoreTask = RestoreTask.builder() + .restoreTargets(restoreTargets) + .threads(threads) + .dryRun(false) + .deleteFilesNotInBackup(deleteLeftOver) + .build(); + restoreFullBackup.execute(restoreTask); + //verify, that the restore used the earlier increment + final var realRestorePath = restoreTargets.mapToRestorePath(sourceDir); + Assertions.assertTrue(Files.exists(realRestorePath.resolve("folder/deleted.png"))); + //recreate restore controller to read new backup increment final var underTest = new RestoreController( backupDir, configuration.getFileNamePrefix(), decryptionKey); @@ -429,7 +435,6 @@ void testExecuteShouldRestoreFilesToDestinationWhenExecutedWithIncrementalBackup underTest.execute(restoreTask); //then - final var realRestorePath = restoreTargets.mapToRestorePath(sourceDir); final var metadataParser = FileMetadataParserFactory.newInstance(); for (final var sourceFile : originalFiles) { final var restoredFile = realRestorePath.resolve(sourceFile.getFileName().toString()); diff --git a/file-barj-job/README.md b/file-barj-job/README.md index 73ee5e7..2800e84 100644 --- a/file-barj-job/README.md +++ b/file-barj-job/README.md @@ -62,6 +62,7 @@ java -jar build/libs/file-barj-job.jar \ --delete-missing true \ --key-store keys.p12 \ --key-alias alias \ + --at-epoch-seconds 123456 \ --threads 2 ``` 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 180c51e..24e74c5 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 @@ -130,7 +130,7 @@ protected void doRestore(final RestoreProperties properties) { .dryRun(properties.isDryRun()) .deleteFilesNotInBackup(properties.isDeleteFilesNotInBackup()) .build(); - new RestoreController(properties.getBackupSource(), properties.getPrefix(), kek) + new RestoreController(properties.getBackupSource(), properties.getPrefix(), kek, properties.getPointInTimeEpochSeconds()) .execute(restoreTask); final var endTimeMillis = System.currentTimeMillis(); final var durationMillis = (endTimeMillis - startTimeMillis); diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliRestoreParser.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliRestoreParser.java index 01345ce..6875ff2 100644 --- a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliRestoreParser.java +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/CliRestoreParser.java @@ -6,6 +6,7 @@ import java.io.Console; import java.nio.file.Path; +import java.time.Instant; import java.util.Arrays; import java.util.HashMap; @@ -19,6 +20,7 @@ public class CliRestoreParser extends CliICommonBackupFileParser(); if (commandLine.hasOption(TARGET)) { final var mappings = commandLine.getOptionValues(TARGET); @@ -55,6 +59,7 @@ public CliRestoreParser(final String[] args, final Console console) { .keyProperties(keyProperties) .prefix(prefix) .targets(targets) + .pointInTimeEpochSeconds(atPointInTime) .build(); }); } @@ -89,6 +94,14 @@ protected Options createOptions() { .argName("from_dir=to_dir") .desc("Defines where the files should be restored to by defining mappings that can specify which from_dir" + " of the backup source should be restored to which to_dir (as if the two were equivalent). Optional.") + .build()) + .addOption(Option.builder() + .longOpt(AT_EPOCH_SECONDS) + .numberOfArgs(1) + .argName("epoch_seconds") + .required(false) + .type(Long.class) + .desc("The date and time using UTC epoch seconds at which the content should be restored.") .build()); } } diff --git a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/RestoreProperties.java b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/RestoreProperties.java index 583756e..0a403ae 100644 --- a/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/RestoreProperties.java +++ b/file-barj-job/src/main/java/com/github/nagyesta/filebarj/job/cli/RestoreProperties.java @@ -20,4 +20,5 @@ public class RestoreProperties extends BackupFileProperties { private final int threads; private final boolean dryRun; private final boolean deleteFilesNotInBackup; + private final long pointInTimeEpochSeconds; } 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 e216cdd..cf3bd77 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 @@ -13,6 +13,7 @@ import java.io.Console; import java.nio.file.Files; +import java.time.Instant; import java.util.Set; import static org.mockito.ArgumentMatchers.anyString; @@ -21,6 +22,8 @@ class ControllerIntegrationTest extends TempFileAwareTest { + private static final long A_SECOND = 1000; + @Test void testEndToEndFlowShouldWorkWhenCalledWithValidParameters() throws Exception { //given @@ -34,7 +37,8 @@ void testEndToEndFlowShouldWorkWhenCalledWithValidParameters() throws Exception Files.createDirectories(originalDirectory); final var txtFileName = "file1.txt"; final var txt = originalDirectory.resolve(txtFileName); - Files.writeString(txt, "test"); + final var originalTxtContent = "test"; + Files.writeString(txt, originalTxtContent); final var backupDirectory = testDataRoot.resolve("backup"); final var keyStoreArgs = new String[]{ "--gen-keys", @@ -48,7 +52,7 @@ void testEndToEndFlowShouldWorkWhenCalledWithValidParameters() throws Exception //given we have a backup configuration final var config = testDataRoot.resolve("config.json"); new ObjectMapper().writeValue(config.toFile(), BackupJobConfiguration.builder() - .backupType(BackupType.FULL) + .backupType(BackupType.INCREMENTAL) .fileNamePrefix(prefix) .duplicateStrategy(DuplicateHandlingStrategy.KEEP_EACH) .destinationDirectory(backupDirectory) @@ -65,24 +69,50 @@ void testEndToEndFlowShouldWorkWhenCalledWithValidParameters() throws Exception //when backup is executed new Controller(backupArgs, console).run(); + final var atEpochSeconds = Instant.now().getEpochSecond(); + Thread.sleep(A_SECOND); + final var modifiedTxtContent = "updated value"; + Files.writeString(txt, modifiedTxtContent); + + //when another backup increment is executed + new Controller(backupArgs, console).run(); + //given we prepare for restore final var restoreDirectory = testDataRoot.resolve("restore"); - final var restoreArgs = new String[]{ + final var restoreFullArgs = new String[]{ "--restore", "--target-mapping", originalDirectory + "=" + restoreDirectory, "--backup-source", backupDirectory.toString(), "--prefix", prefix, "--key-store", keyStore.toString(), - "--key-alias", alias + "--key-alias", alias, + "--at-epoch-seconds", atEpochSeconds + "" }; //when restore is executed - new Controller(restoreArgs, console).run(); + new Controller(restoreFullArgs, console).run(); - //then the file exists in the restore directory + //then the original content of the file exists in the restore directory final var restoredTxt = restoreDirectory.resolve(txtFileName); Assertions.assertTrue(Files.exists(restoredTxt)); - Assertions.assertEquals(Files.readString(txt), Files.readString(restoredTxt)); + Assertions.assertEquals(originalTxtContent, Files.readString(restoredTxt)); + + //then restore the latest backup increment + final var restoreLatestArgs = new String[]{ + "--restore", + "--target-mapping", originalDirectory + "=" + restoreDirectory, + "--backup-source", backupDirectory.toString(), + "--prefix", prefix, + "--key-store", keyStore.toString(), + "--key-alias", alias + }; + + //when restore is executed with the last increment + new Controller(restoreLatestArgs, console).run(); + + //then the file exists in the restore directory + Assertions.assertTrue(Files.exists(restoredTxt)); + Assertions.assertEquals(modifiedTxtContent, Files.readString(restoredTxt)); //given we inspect the versions final var inspectIncrementsArgs = new String[]{