Skip to content

Commit

Permalink
Allow point-in-time restore (#124)
Browse files Browse the repository at this point in the history
- Adds new option to --restore command
- Adds new parameter to RestoreController
- Updates tests
- Updates README.md

Resolves #108
{minor}

Signed-off-by: Esta Nagy <[email protected]>
  • Loading branch information
nagyesta authored Feb 2, 2024
1 parent e8194ac commit f955b29
Show file tree
Hide file tree
Showing 9 changed files with 95 additions and 25 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion file-barj-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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());
Expand Down
1 change: 1 addition & 0 deletions file-barj-job/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,6 +20,7 @@ public class CliRestoreParser extends CliICommonBackupFileParser<RestoreProperti
private static final String DRY_RUN = "dry-run";
private static final String TARGET = "target-mapping";
private static final String DELETE_MISSING = "delete-missing";
private static final String AT_EPOCH_SECONDS = "at-epoch-seconds";

/**
* Creates a new {@link CliRestoreParser} instance and sets the input arguments.
Expand All @@ -33,6 +35,8 @@ public CliRestoreParser(final String[] args, final Console console) {
final var deleteMissing = Boolean.parseBoolean(commandLine.getOptionValue(DELETE_MISSING, "false"));
final var backupSource = Path.of(commandLine.getOptionValue(BACKUP_SOURCE)).toAbsolutePath();
final var prefix = commandLine.getOptionValue(PREFIX);
final var nowEpochSeconds = Instant.now().getEpochSecond() + "";
final var atPointInTime = Long.parseLong(commandLine.getOptionValue(AT_EPOCH_SECONDS, nowEpochSeconds));
final var targets = new HashMap<Path, Path>();
if (commandLine.hasOption(TARGET)) {
final var mappings = commandLine.getOptionValues(TARGET);
Expand All @@ -55,6 +59,7 @@ public CliRestoreParser(final String[] args, final Console console) {
.keyProperties(keyProperties)
.prefix(prefix)
.targets(targets)
.pointInTimeEpochSeconds(atPointInTime)
.build();
});
}
Expand Down Expand Up @@ -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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,8 @@

class ControllerIntegrationTest extends TempFileAwareTest {

private static final long A_SECOND = 1000;

@Test
void testEndToEndFlowShouldWorkWhenCalledWithValidParameters() throws Exception {
//given
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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[]{
Expand Down

0 comments on commit f955b29

Please sign in to comment.