/*
* CDDL HEADER START
*
* The contents of this file are subject to the terms of the
* Common Development and Distribution License, Version 1.0 only
* (the "License"). You may not use this file except in compliance
* with the License.
*
* You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
* or http://forgerock.org/license/CDDLv1.0.html.
* See the License for the specific language governing permissions
* and limitations under the License.
*
* When distributing Covered Code, include this CDDL HEADER in each
* file and include the License file at legal-notices/CDDLv1_0.txt.
* If applicable, add the following below this CDDL HEADER, with the
* fields enclosed by brackets "[]" replaced with your own identifying
* information:
* Portions Copyright [yyyy] [name of copyright owner]
*
* CDDL HEADER END
*
*
* Copyright 2006-2009 Sun Microsystems, Inc.
* Portions Copyright 2013-2015 ForgeRock AS.
*/
package org.opends.server.util;
import static java.util.Collections.*;
import static org.opends.messages.BackendMessages.*;
import static org.opends.messages.UtilityMessages.*;
import static org.opends.server.util.ServerConstants.*;
import static org.opends.server.util.StaticUtils.*;
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import javax.crypto.Mac;
import org.forgerock.i18n.LocalizableMessage;
import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.config.server.ConfigException;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.util.Pair;
import org.opends.server.api.Backupable;
import org.opends.server.core.DirectoryServer;
import org.opends.server.types.BackupConfig;
import org.opends.server.types.BackupDirectory;
import org.opends.server.types.BackupInfo;
import org.opends.server.types.CryptoManager;
import org.opends.server.types.CryptoManagerException;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.RestoreConfig;
/**
* A backup manager for any entity that is backupable (backend, storage).
*
* @see {@link Backupable}
*/
public class BackupManager
{
private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
/**
* The common prefix for archive files.
*/
private static final String BACKUP_BASE_FILENAME = "backup-";
/**
* The name of the property that holds the name of the latest log file
* at the time the backup was created.
*/
private static final String PROPERTY_LAST_LOGFILE_NAME = "last_logfile_name";
/**
* The name of the property that holds the size of the latest log file
* at the time the backup was created.
*/
private static final String PROPERTY_LAST_LOGFILE_SIZE = "last_logfile_size";
/**
* The name of the entry in an incremental backup archive file
* containing a list of log files that are unchanged since the
* previous backup.
*/
private static final String ZIPENTRY_UNCHANGED_LOGFILES = "unchanged.txt";
/**
* The name of a dummy entry in the backup archive file that will act
* as a placeholder in case a backup is done on an empty backend.
*/
private static final String ZIPENTRY_EMPTY_PLACEHOLDER = "empty.placeholder";
/**
* The backend ID.
*/
private final String backendID;
/**
* Construct a backup manager for a backend.
*
* @param backendID
* The ID of the backend instance for which a backup manager is
* required.
*/
public BackupManager(String backendID)
{
this.backendID = backendID;
}
/** A cryptographic engine to use for backup creation or restore. */
private static abstract class CryptoEngine
{
final CryptoManager cryptoManager;
final boolean shouldEncrypt;
/** Creates a crypto engine for archive creation. */
static CryptoEngine forCreation(BackupConfig backupConfig, NewBackupParams backupParams)
throws DirectoryException {
if (backupConfig.hashData())
{
if (backupConfig.signHash())
{
return new MacCryptoEngine(backupConfig, backupParams);
}
else
{
return new DigestCryptoEngine(backupConfig, backupParams);
}
}
else
{
return new NoHashCryptoEngine(backupConfig.encryptData());
}
}
/** Creates a crypto engine for archive restore. */
static CryptoEngine forRestore(BackupInfo backupInfo)
throws DirectoryException {
boolean hasSignedHash = backupInfo.getSignedHash() != null;
boolean hasHashData = hasSignedHash || backupInfo.getUnsignedHash() != null;
if (hasHashData)
{
if (hasSignedHash)
{
return new MacCryptoEngine(backupInfo);
}
else
{
return new DigestCryptoEngine(backupInfo);
}
}
else
{
return new NoHashCryptoEngine(backupInfo.isEncrypted());
}
}
CryptoEngine(boolean shouldEncrypt)
{
cryptoManager = DirectoryServer.getCryptoManager();
this.shouldEncrypt = shouldEncrypt;
}
/** Indicates if data is encrypted. */
final boolean shouldEncrypt() {
return shouldEncrypt;
}
/** Indicates if hashed data is signed. */
boolean hasSignedHash() {
return false;
}
/** Update the hash with the provided string. */
abstract void updateHashWith(String s);
/** Update the hash with the provided buffer. */
abstract void updateHashWith(byte[] buffer, int offset, int len);
/** Generates the hash bytes. */
abstract byte[] generateBytes();
/** Returns the error message to use in case of check failure. */
abstract LocalizableMessage getErrorMessageForCheck(String backupID);
/** Check that generated hash is equal to the provided hash. */
final void check(byte[] hash, String backupID) throws DirectoryException
{
byte[] bytes = generateBytes();
if (bytes != null && !Arrays.equals(bytes, hash))
{
LocalizableMessage message = getErrorMessageForCheck(backupID);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message);
}
}
/** Wraps an output stream in a cipher output stream if encryption is required. */
final OutputStream encryptOutput(OutputStream output) throws DirectoryException
{
if (!shouldEncrypt())
{
return output;
}
try
{
return cryptoManager.getCipherOutputStream(output);
}
catch (CryptoManagerException e)
{
logger.traceException(e);
StaticUtils.close(output);
LocalizableMessage message = ERR_BACKUP_CANNOT_GET_CIPHER.get(stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
}
/** Wraps an input stream in a cipher input stream if encryption is required. */
final InputStream encryptInput(InputStream inputStream) throws DirectoryException
{
if (!shouldEncrypt)
{
return inputStream;
}
try
{
return cryptoManager.getCipherInputStream(inputStream);
}
catch (CryptoManagerException e)
{
logger.traceException(e);
StaticUtils.close(inputStream);
LocalizableMessage message = ERR_BACKUP_CANNOT_GET_CIPHER.get(stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
}
}
/** Represents the cryptographic engine with no hash used for a backup. */
private static final class NoHashCryptoEngine extends CryptoEngine
{
NoHashCryptoEngine(boolean shouldEncrypt)
{
super(shouldEncrypt);
}
@Override
void updateHashWith(String s)
{
// nothing to do
}
@Override
void updateHashWith(byte[] buffer, int offset, int len)
{
// nothing to do
}
@Override
byte[] generateBytes()
{
return null;
}
@Override
LocalizableMessage getErrorMessageForCheck(String backupID)
{
// check never fails because bytes are always null
return null;
}
}
/**
* Represents the cryptographic engine with signed hash.
*/
private static final class MacCryptoEngine extends CryptoEngine
{
private Mac mac;
/** Constructor for backup creation. */
private MacCryptoEngine(BackupConfig backupConfig, NewBackupParams backupParams) throws DirectoryException
{
super(backupConfig.encryptData());
String macKeyID = null;
try
{
macKeyID = cryptoManager.getMacEngineKeyEntryID();
backupParams.putProperty(BACKUP_PROPERTY_MAC_KEY_ID, macKeyID);
}
catch (CryptoManagerException e)
{
LocalizableMessage message = ERR_BACKUP_CANNOT_GET_MAC_KEY_ID.get(backupParams.backupID,
stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
retrieveMacEngine(macKeyID);
}
/** Constructor for backup restore. */
private MacCryptoEngine(BackupInfo backupInfo) throws DirectoryException
{
super(backupInfo.isEncrypted());
HashMap<String,String> backupProperties = backupInfo.getBackupProperties();
String macKeyID = backupProperties.get(BACKUP_PROPERTY_MAC_KEY_ID);
retrieveMacEngine(macKeyID);
}
private void retrieveMacEngine(String macKeyID) throws DirectoryException
{
try
{
mac = cryptoManager.getMacEngine(macKeyID);
}
catch (Exception e)
{
LocalizableMessage message = ERR_BACKUP_CANNOT_GET_MAC.get(macKeyID, stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
}
/** {@inheritDoc} */
@Override
void updateHashWith(String s)
{
mac.update(getBytes(s));
}
/** {@inheritDoc} */
@Override
void updateHashWith(byte[] buffer, int offset, int len)
{
mac.update(buffer, offset, len);
}
@Override
byte[] generateBytes()
{
return mac.doFinal();
}
@Override
boolean hasSignedHash()
{
return true;
}
@Override
LocalizableMessage getErrorMessageForCheck(String backupID)
{
return ERR_BACKUP_SIGNED_HASH_ERROR.get(backupID);
}
@Override
public String toString()
{
return "MacCryptoEngine [mac=" + mac + "]";
}
}
/** Represents the cryptographic engine with unsigned hash used for a backup. */
private static final class DigestCryptoEngine extends CryptoEngine
{
private final MessageDigest digest;
/** Constructor for backup creation. */
private DigestCryptoEngine(BackupConfig backupConfig, NewBackupParams backupParams) throws DirectoryException
{
super(backupConfig.encryptData());
String digestAlgorithm = cryptoManager.getPreferredMessageDigestAlgorithm();
backupParams.putProperty(BACKUP_PROPERTY_DIGEST_ALGORITHM, digestAlgorithm);
digest = retrieveMessageDigest(digestAlgorithm);
}
/** Constructor for backup restore. */
private DigestCryptoEngine(BackupInfo backupInfo) throws DirectoryException
{
super(backupInfo.isEncrypted());
HashMap<String, String> backupProperties = backupInfo.getBackupProperties();
String digestAlgorithm = backupProperties.get(BACKUP_PROPERTY_DIGEST_ALGORITHM);
digest = retrieveMessageDigest(digestAlgorithm);
}
private MessageDigest retrieveMessageDigest(String digestAlgorithm) throws DirectoryException
{
try
{
return cryptoManager.getMessageDigest(digestAlgorithm);
}
catch (Exception e)
{
LocalizableMessage message =
ERR_BACKUP_CANNOT_GET_DIGEST.get(digestAlgorithm, stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
}
/** {@inheritDoc} */
@Override
public void updateHashWith(String s)
{
digest.update(getBytes(s));
}
/** {@inheritDoc} */
@Override
public void updateHashWith(byte[] buffer, int offset, int len)
{
digest.update(buffer, offset, len);
}
/** {@inheritDoc} */
@Override
public byte[] generateBytes()
{
return digest.digest();
}
/** {@inheritDoc} */
@Override
LocalizableMessage getErrorMessageForCheck(String backupID)
{
return ERR_BACKUP_UNSIGNED_HASH_ERROR.get(backupID);
}
@Override
public String toString()
{
return "DigestCryptoEngine [digest=" + digest + "]";
}
}
/**
* Contains all parameters for creation of a new backup.
*/
private static final class NewBackupParams
{
final String backupID;
final BackupDirectory backupDir;
final HashMap<String,String> backupProperties;
final boolean shouldCompress;
final boolean isIncremental;
final String incrementalBaseID;
final BackupInfo baseBackupInfo;
NewBackupParams(BackupConfig backupConfig) throws DirectoryException
{
backupID = backupConfig.getBackupID();
backupDir = backupConfig.getBackupDirectory();
backupProperties = new HashMap<>();
shouldCompress = backupConfig.compressData();
incrementalBaseID = retrieveIncrementalBaseID(backupConfig);
isIncremental = incrementalBaseID != null;
baseBackupInfo = isIncremental ? getBackupInfo(backupDir, incrementalBaseID) : null;
}
private String retrieveIncrementalBaseID(BackupConfig backupConfig)
{
String id = null;
if (backupConfig.isIncremental())
{
if (backupConfig.getIncrementalBaseID() == null && backupDir.getLatestBackup() != null)
{
// The default is to use the latest backup as base.
id = backupDir.getLatestBackup().getBackupID();
}
else
{
id = backupConfig.getIncrementalBaseID();
}
if (id == null)
{
// No incremental backup ID: log a message informing that a backup
// could not be found and that a normal backup will be done.
logger.warn(WARN_BACKUPDB_INCREMENTAL_NOT_FOUND_DOING_NORMAL, backupDir.getPath());
}
}
return id;
}
void putProperty(String name, String value) {
backupProperties.put(name, value);
}
@Override
public String toString()
{
return "BackupCreationParams [backupID=" + backupID + ", backupDir=" + backupDir.getPath() + "]";
}
}
/** Represents a new backup archive. */
private static final class NewBackupArchive {
private final String archiveFilename;
private String latestFileName;
private long latestFileSize;
private final HashSet<String> dependencies;
private final String backendID;
private final NewBackupParams newBackupParams;
private final CryptoEngine cryptoEngine;
NewBackupArchive(String backendID, NewBackupParams backupParams, CryptoEngine crypt)
{
this.backendID = backendID;
this.newBackupParams = backupParams;
this.cryptoEngine = crypt;
dependencies = new HashSet<>();
if (backupParams.isIncremental)
{
HashMap<String,String> properties = backupParams.baseBackupInfo.getBackupProperties();
latestFileName = properties.get(PROPERTY_LAST_LOGFILE_NAME);
latestFileSize = Long.parseLong(properties.get(PROPERTY_LAST_LOGFILE_SIZE));
}
archiveFilename = BACKUP_BASE_FILENAME + backendID + "-" + backupParams.backupID;
}
String getArchiveFilename()
{
return archiveFilename;
}
String getBackendID()
{
return backendID;
}
String getBackupID()
{
return newBackupParams.backupID;
}
String getBackupPath() {
return newBackupParams.backupDir.getPath();
}
void addBaseBackupAsDependency() {
dependencies.add(newBackupParams.baseBackupInfo.getBackupID());
}
void updateBackupDirectory() throws DirectoryException
{
BackupInfo backupInfo = createDescriptorForBackup();
try
{
newBackupParams.backupDir.addBackup(backupInfo);
newBackupParams.backupDir.writeBackupDirectoryDescriptor();
}
catch (Exception e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_UPDATE_BACKUP_DESCRIPTOR.get(
newBackupParams.backupDir.getDescriptorPath(), stackTraceToSingleLineString(e)),
e);
}
}
/** Create a descriptor for the backup. */
private BackupInfo createDescriptorForBackup()
{
byte[] bytes = cryptoEngine.generateBytes();
byte[] digestBytes = cryptoEngine.hasSignedHash() ? null : bytes;
byte[] macBytes = cryptoEngine.hasSignedHash() ? bytes : null;
newBackupParams.putProperty(PROPERTY_LAST_LOGFILE_NAME, latestFileName);
newBackupParams.putProperty(PROPERTY_LAST_LOGFILE_SIZE, String.valueOf(latestFileSize));
return new BackupInfo(
newBackupParams.backupDir, newBackupParams.backupID, new Date(), newBackupParams.isIncremental,
newBackupParams.shouldCompress, cryptoEngine.shouldEncrypt(), digestBytes, macBytes,
dependencies, newBackupParams.backupProperties);
}
@Override
public String toString()
{
return "NewArchive [archive file=" + archiveFilename + ", latestFileName=" + latestFileName
+ ", backendID=" + backendID + "]";
}
}
/** Represents an existing backup archive. */
private static final class ExistingBackupArchive {
private final String backupID;
private final BackupDirectory backupDir;
private final BackupInfo backupInfo;
private final CryptoEngine cryptoEngine;
private final File archiveFile;
ExistingBackupArchive(String backupID, BackupDirectory backupDir) throws DirectoryException
{
this.backupID = backupID;
this.backupDir = backupDir;
this.backupInfo = BackupManager.getBackupInfo(backupDir, backupID);
this.cryptoEngine = CryptoEngine.forRestore(backupInfo);
this.archiveFile = BackupManager.retrieveArchiveFile(backupInfo, backupDir.getPath());
}
File getArchiveFile()
{
return archiveFile;
}
BackupInfo getBackupInfo() {
return backupInfo;
}
String getBackupID()
{
return backupID;
}
CryptoEngine getCryptoEngine()
{
return cryptoEngine;
}
/**
* Obtains a list of the dependencies of this backup in order from
* the oldest (the full backup), to the most recent.
*
* @return A list of dependent backups.
* @throws DirectoryException If a Directory Server error occurs.
*/
List<BackupInfo> getBackupDependencies() throws DirectoryException
{
List<BackupInfo> dependencies = new ArrayList<>();
BackupInfo currentBackupInfo = backupInfo;
while (currentBackupInfo != null && !currentBackupInfo.getDependencies().isEmpty())
{
String backupID = currentBackupInfo.getDependencies().iterator().next();
currentBackupInfo = backupDir.getBackupInfo(backupID);
if (currentBackupInfo != null)
{
dependencies.add(currentBackupInfo);
}
}
Collections.reverse(dependencies);
return dependencies;
}
boolean hasDependencies()
{
return !backupInfo.getDependencies().isEmpty();
}
/** Removes the archive from file system. */
boolean removeArchive() throws DirectoryException
{
try
{
backupDir.removeBackup(backupID);
backupDir.writeBackupDirectoryDescriptor();
}
catch (ConfigException e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), e.getMessageObject());
}
catch (Exception e)
{
logger.traceException(e);
LocalizableMessage message = ERR_BACKUP_CANNOT_UPDATE_BACKUP_DESCRIPTOR.get(
backupDir.getDescriptorPath(), stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
return archiveFile.delete();
}
}
/** Represents a writer of a backup archive. */
private static final class BackupArchiveWriter implements Closeable {
private final ZipOutputStream zipOutputStream;
private final NewBackupArchive archive;
private final CryptoEngine cryptoEngine;
BackupArchiveWriter(NewBackupArchive archive) throws DirectoryException
{
this.archive = archive;
this.cryptoEngine = archive.cryptoEngine;
this.zipOutputStream = open(archive.getBackupPath(), archive.getArchiveFilename());
}
@Override
public void close() throws IOException
{
StaticUtils.close(zipOutputStream);
}
/**
* Writes the provided file to a new entry in the archive.
*
* @param file
* The file to be written.
* @param cryptoMethod
* The cryptographic method for the written data.
* @param backupConfig
* The configuration, used to know if operation is cancelled.
*
* @return The number of bytes written from the file.
* @throws FileNotFoundException If the file to be archived does not exist.
* @throws IOException If an I/O error occurs while archiving the file.
*/
long writeFile(Path file, String relativePath, CryptoEngine cryptoMethod, BackupConfig backupConfig)
throws IOException, FileNotFoundException
{
ZipEntry zipEntry = new ZipEntry(relativePath);
zipOutputStream.putNextEntry(zipEntry);
cryptoMethod.updateHashWith(relativePath);
InputStream inputStream = null;
long totalBytesRead = 0;
try {
inputStream = new FileInputStream(file.toFile());
byte[] buffer = new byte[8192];
int bytesRead = inputStream.read(buffer);
while (bytesRead > 0 && !backupConfig.isCancelled())
{
cryptoMethod.updateHashWith(buffer, 0, bytesRead);
zipOutputStream.write(buffer, 0, bytesRead);
totalBytesRead += bytesRead;
bytesRead = inputStream.read(buffer);
}
}
finally {
StaticUtils.close(inputStream);
}
zipOutputStream.closeEntry();
logger.info(NOTE_BACKUP_ARCHIVED_FILE, zipEntry.getName());
return totalBytesRead;
}
/**
* Write a list of strings to an entry in the archive.
*
* @param stringList
* A list of strings to be written. The strings must not
* contain newlines.
* @param fileName
* The name of the zip entry to be written.
* @param cryptoMethod
* The cryptographic method for the written data.
* @throws IOException
* If an I/O error occurs while writing the archive entry.
*/
void writeStrings(List<String> stringList, String fileName, CryptoEngine cryptoMethod)
throws IOException
{
ZipEntry zipEntry = new ZipEntry(fileName);
zipOutputStream.putNextEntry(zipEntry);
cryptoMethod.updateHashWith(fileName);
Writer writer = new OutputStreamWriter(zipOutputStream);
for (String s : stringList)
{
cryptoMethod.updateHashWith(s);
writer.write(s);
writer.write(EOL);
}
writer.flush();
zipOutputStream.closeEntry();
}
/** Writes a empty placeholder entry into the archive. */
void writeEmptyPlaceHolder() throws DirectoryException
{
try
{
ZipEntry emptyPlaceholder = new ZipEntry(ZIPENTRY_EMPTY_PLACEHOLDER);
zipOutputStream.putNextEntry(emptyPlaceholder);
}
catch (IOException e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_WRITE_ARCHIVE_FILE.get(ZIPENTRY_EMPTY_PLACEHOLDER, archive.getBackupID(),
stackTraceToSingleLineString(e)),
e);
}
}
/**
* Writes the files that are unchanged from the base backup (for an
* incremental backup only).
* <p>
* The unchanged files names are listed in the "unchanged.txt" file, which
* is put in the archive.
*
*/
void writeUnchangedFiles(Path rootDirectory, ListIterator<Path> files, BackupConfig backupConfig)
throws DirectoryException
{
List<String> unchangedFilenames = new ArrayList<>();
while (files.hasNext() && !backupConfig.isCancelled())
{
Path file = files.next();
String relativePath = rootDirectory.relativize(file).toString();
int cmp = relativePath.compareTo(archive.latestFileName);
if (cmp > 0 || (cmp == 0 && file.toFile().length() != archive.latestFileSize))
{
files.previous();
break;
}
logger.info(NOTE_BACKUP_FILE_UNCHANGED, relativePath);
unchangedFilenames.add(relativePath);
}
if (!unchangedFilenames.isEmpty())
{
writeUnchangedFilenames(unchangedFilenames);
}
}
/** Writes the list of unchanged files names in a file as new entry in the archive. */
private void writeUnchangedFilenames(List<String> unchangedList) throws DirectoryException
{
String zipEntryName = ZIPENTRY_UNCHANGED_LOGFILES;
try
{
writeStrings(unchangedList, zipEntryName, archive.cryptoEngine);
}
catch (IOException e)
{
logger.traceException(e);
throw new DirectoryException(
DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_WRITE_ARCHIVE_FILE.get(zipEntryName, archive.getBackupID(),
stackTraceToSingleLineString(e)), e);
}
archive.addBaseBackupAsDependency();
}
/**
* Writes the new files in the archive.
*/
void writeChangedFiles(Path rootDirectory, ListIterator<Path> files, BackupConfig backupConfig)
throws DirectoryException
{
while (files.hasNext() && !backupConfig.isCancelled())
{
Path file = files.next();
String relativePath = rootDirectory.relativize(file).toString();
try
{
archive.latestFileSize = writeFile(file, relativePath, archive.cryptoEngine, backupConfig);
archive.latestFileName = relativePath;
}
catch (FileNotFoundException e)
{
// The file may have been deleted by a cleaner (i.e. for JE storage) since we started.
// The backupable entity is responsible for handling the changes through the files list iterator
logger.traceException(e);
}
catch (IOException e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_WRITE_ARCHIVE_FILE.get(relativePath, archive.getBackupID(),
stackTraceToSingleLineString(e)), e);
}
}
}
private ZipOutputStream open(String backupPath, String archiveFilename) throws DirectoryException
{
OutputStream output = openStream(backupPath, archiveFilename);
output = cryptoEngine.encryptOutput(output);
return openZipStream(output);
}
private OutputStream openStream(String backupPath, String archiveFilename) throws DirectoryException {
OutputStream output = null;
try
{
File archiveFile = new File(backupPath, archiveFilename);
int i = 1;
while (archiveFile.exists())
{
archiveFile = new File(backupPath, archiveFilename + "." + i);
i++;
}
output = new FileOutputStream(archiveFile, false);
archive.newBackupParams.putProperty(BACKUP_PROPERTY_ARCHIVE_FILENAME, archiveFilename);
return output;
}
catch (Exception e)
{
logger.traceException(e);
StaticUtils.close(output);
LocalizableMessage message = ERR_BACKUP_CANNOT_CREATE_ARCHIVE_FILE.
get(archiveFilename, backupPath, archive.getBackupID(), stackTraceToSingleLineString(e));
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message, e);
}
}
/** Wraps the file output stream in a zip output stream. */
private ZipOutputStream openZipStream(OutputStream outputStream)
{
ZipOutputStream zipStream = new ZipOutputStream(outputStream);
zipStream.setComment(ERR_BACKUP_ZIP_COMMENT.get(DynamicConstants.PRODUCT_NAME, archive.getBackupID())
.toString());
if (archive.newBackupParams.shouldCompress)
{
zipStream.setLevel(Deflater.DEFAULT_COMPRESSION);
}
else
{
zipStream.setLevel(Deflater.NO_COMPRESSION);
}
return zipStream;
}
@Override
public String toString()
{
return "BackupArchiveWriter [archive file=" + archive.getArchiveFilename() + ", backendId="
+ archive.getBackendID() + "]";
}
}
/** Represents a reader of a backup archive. */
private static final class BackupArchiveReader {
private final CryptoEngine cryptoEngine;
private final File archiveFile;
private final String identifier;
private final BackupInfo backupInfo;
BackupArchiveReader(String identifier, ExistingBackupArchive archive)
{
this.identifier = identifier;
this.backupInfo = archive.getBackupInfo();
this.archiveFile = archive.getArchiveFile();
this.cryptoEngine = archive.getCryptoEngine();
}
BackupArchiveReader(String identifier, BackupInfo backupInfo, String backupDirectoryPath) throws DirectoryException
{
this.identifier = identifier;
this.backupInfo = backupInfo;
this.archiveFile = BackupManager.retrieveArchiveFile(backupInfo, backupDirectoryPath);
this.cryptoEngine = CryptoEngine.forRestore(backupInfo);
}
/**
* Obtains the set of files in a backup that are unchanged from its
* dependent backup or backups.
* <p>
* The file set is stored as as the first entry in the archive file.
*
* @return The set of files that are listed in "unchanged.txt" file
* of the archive.
* @throws DirectoryException
* If an error occurs.
*/
Set<String> readUnchangedDependentFiles() throws DirectoryException
{
Set<String> hashSet = new HashSet<>();
ZipInputStream zipStream = null;
try
{
zipStream = openZipStream();
// Iterate through the entries in the zip file.
ZipEntry zipEntry = zipStream.getNextEntry();
while (zipEntry != null)
{
// We are looking for the entry containing the list of unchanged files.
if (ZIPENTRY_UNCHANGED_LOGFILES.equals(zipEntry.getName()))
{
hashSet.addAll(readAllLines(zipStream));
break;
}
zipEntry = zipStream.getNextEntry();
}
return hashSet;
}
catch (IOException e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), ERR_BACKUP_CANNOT_RESTORE.get(
identifier, stackTraceToSingleLineString(e)), e);
}
finally {
StaticUtils.close(zipStream);
}
}
/**
* Restore the provided list of files from the provided restore directory.
* @param restoreDir
* The target directory for restored files.
* @param filesToRestore
* The set of files to restore. If empty, all files in the archive
* are restored.
* @param restoreConfig
* The restore configuration, used to check for cancellation of
* this restore operation.
* @throws DirectoryException
* If an error occurs.
*/
void restoreArchive(Path restoreDir, Set<String> filesToRestore, RestoreConfig restoreConfig, Backupable backupable)
throws DirectoryException
{
try
{
restoreArchive0(restoreDir, filesToRestore, restoreConfig, backupable);
}
catch (IOException e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_RESTORE.get(identifier, stackTraceToSingleLineString(e)), e);
}
// check the hash
byte[] hash = backupInfo.getUnsignedHash() != null ? backupInfo.getUnsignedHash() : backupInfo.getSignedHash();
cryptoEngine.check(hash, backupInfo.getBackupID());
}
private void restoreArchive0(Path restoreDir, Set<String> filesToRestore, RestoreConfig restoreConfig,
Backupable backupable) throws DirectoryException, IOException {
ZipInputStream zipStream = null;
try {
zipStream = openZipStream();
ZipEntry zipEntry = zipStream.getNextEntry();
while (zipEntry != null && !restoreConfig.isCancelled())
{
String zipEntryName = zipEntry.getName();
Pair<Boolean, ZipEntry> result = handleSpecialEntries(zipStream, zipEntryName);
if (result.getFirst()) {
zipEntry = result.getSecond();
continue;
}
boolean mustRestoreOnDisk = !restoreConfig.verifyOnly()
&& (filesToRestore.isEmpty() || filesToRestore.contains(zipEntryName));
if (mustRestoreOnDisk)
{
restoreZipEntry(zipEntryName, zipStream, restoreDir, restoreConfig);
}
else
{
restoreZipEntryVirtual(zipEntryName, zipStream, restoreConfig);
}
zipEntry = zipStream.getNextEntry();
}
}
finally {
StaticUtils.close(zipStream);
}
}
/**
* Handle any special entry in the archive.
*
* @return the pair (true, zipEntry) if next entry was read, (false, null) otherwise
*/
private Pair<Boolean, ZipEntry> handleSpecialEntries(ZipInputStream zipStream, String zipEntryName)
throws IOException
{
if (ZIPENTRY_EMPTY_PLACEHOLDER.equals(zipEntryName))
{
// the backup contains no files
return Pair.of(true, zipStream.getNextEntry());
}
if (ZIPENTRY_UNCHANGED_LOGFILES.equals(zipEntryName))
{
// This entry is treated specially. It is never restored,
// and its hash is computed on the strings, not the bytes.
cryptoEngine.updateHashWith(zipEntryName);
List<String> lines = readAllLines(zipStream);
for (String line : lines)
{
cryptoEngine.updateHashWith(line);
}
return Pair.of(true, zipStream.getNextEntry());
}
return Pair.of(false, null);
}
/**
* Restores a zip entry virtually (no actual write on disk).
*/
private void restoreZipEntryVirtual(String zipEntryName, ZipInputStream zipStream, RestoreConfig restoreConfig)
throws FileNotFoundException, IOException
{
if (restoreConfig.verifyOnly())
{
logger.info(NOTE_BACKUP_VERIFY_FILE, zipEntryName);
}
cryptoEngine.updateHashWith(zipEntryName);
restoreFile(zipStream, null, restoreConfig);
}
/**
* Restores a zip entry with actual write on disk.
*/
private void restoreZipEntry(String zipEntryName, ZipInputStream zipStream, Path restoreDir,
RestoreConfig restoreConfig) throws IOException, DirectoryException
{
OutputStream outputStream = null;
long totalBytesRead = 0;
try
{
Path fileToRestore = restoreDir.resolve(zipEntryName);
ensureFileCanBeRestored(fileToRestore);
outputStream = new FileOutputStream(fileToRestore.toFile());
cryptoEngine.updateHashWith(zipEntryName);
totalBytesRead = restoreFile(zipStream, outputStream, restoreConfig);
logger.info(NOTE_BACKUP_RESTORED_FILE, zipEntryName, totalBytesRead);
}
finally
{
StaticUtils.close(outputStream);
}
}
private void ensureFileCanBeRestored(Path fileToRestore) throws DirectoryException
{
Path parent = fileToRestore.getParent();
if (!Files.exists(parent))
{
try
{
Files.createDirectories(parent);
}
catch (IOException e)
{
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_CREATE_DIRECTORY_TO_RESTORE_FILE.get(fileToRestore, identifier));
}
}
}
/**
* Restores the file provided by the zip input stream.
* <p>
* The restore can be virtual: if the outputStream is {@code null}, the file
* is not actually restored on disk.
*/
private long restoreFile(ZipInputStream zipInputStream, OutputStream outputStream, RestoreConfig restoreConfig)
throws IOException
{
long totalBytesRead = 0;
byte[] buffer = new byte[8192];
int bytesRead = zipInputStream.read(buffer);
while (bytesRead > 0 && !restoreConfig.isCancelled())
{
totalBytesRead += bytesRead;
cryptoEngine.updateHashWith(buffer, 0, bytesRead);
if (outputStream != null)
{
outputStream.write(buffer, 0, bytesRead);
}
bytesRead = zipInputStream.read(buffer);
}
return totalBytesRead;
}
private InputStream openStream() throws DirectoryException
{
try
{
return new FileInputStream(archiveFile);
}
catch (FileNotFoundException e)
{
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_RESTORE.get(identifier, stackTraceToSingleLineString(e)), e);
}
}
private ZipInputStream openZipStream() throws DirectoryException
{
InputStream inputStream = openStream();
inputStream = cryptoEngine.encryptInput(inputStream);
return new ZipInputStream(inputStream);
}
private List<String> readAllLines(ZipInputStream zipStream) throws IOException
{
final ArrayList<String> results = new ArrayList<>();
String line;
BufferedReader reader = new BufferedReader(new InputStreamReader(zipStream));
while ((line = reader.readLine()) != null)
{
results.add(line);
}
return results;
}
}
/**
* Creates a backup of the provided backupable entity.
* <p>
* The backup is stored in a single zip file in the backup directory.
* <p>
* If the backup is incremental, then the first entry in the zip is a text
* file containing a list of all the log files that are unchanged since the
* previous backup. The remaining zip entries are the log files themselves,
* which, for an incremental, only include those files that have changed.
*
* @param backupable
* The underlying entity (storage, backend) to be backed up.
* @param backupConfig
* The configuration to use when performing the backup.
* @throws DirectoryException
* If a Directory Server error occurs.
*/
public void createBackup(final Backupable backupable, final BackupConfig backupConfig) throws DirectoryException
{
final NewBackupParams backupParams = new NewBackupParams(backupConfig);
final CryptoEngine cryptoEngine = CryptoEngine.forCreation(backupConfig, backupParams);
final NewBackupArchive newArchive = new NewBackupArchive(backendID, backupParams, cryptoEngine);
BackupArchiveWriter archiveWriter = null;
try
{
final ListIterator<Path> files = backupable.getFilesToBackup();
final Path rootDirectory = backupable.getDirectory().toPath();
archiveWriter = new BackupArchiveWriter(newArchive);
if (files.hasNext())
{
if (backupParams.isIncremental) {
archiveWriter.writeUnchangedFiles(rootDirectory, files, backupConfig);
}
archiveWriter.writeChangedFiles(rootDirectory, files, backupConfig);
}
else {
archiveWriter.writeEmptyPlaceHolder();
}
}
finally
{
closeArchiveWriter(archiveWriter, newArchive.getArchiveFilename(), backupParams.backupDir.getPath());
}
newArchive.updateBackupDirectory();
if (backupConfig.isCancelled())
{
// Remove the backup since it may be incomplete
removeBackup(backupParams.backupDir, backupParams.backupID);
}
}
/**
* Restores a backupable entity from its backup, or verify the backup.
*
* @param backupable
* The underlying entity (storage, backend) to be backed up.
* @param restoreConfig
* The configuration to use when performing the restore.
* @throws DirectoryException
* If a Directory Server error occurs.
*/
public void restoreBackup(Backupable backupable, RestoreConfig restoreConfig) throws DirectoryException
{
Path saveDirectory = null;
if (!restoreConfig.verifyOnly())
{
saveDirectory = backupable.beforeRestore();
}
final String backupID = restoreConfig.getBackupID();
final ExistingBackupArchive existingArchive =
new ExistingBackupArchive(backupID, restoreConfig.getBackupDirectory());
final Path restoreDirectory = getRestoreDirectory(backupable, backupID);
if (existingArchive.hasDependencies())
{
final BackupArchiveReader zipArchiveReader = new BackupArchiveReader(backupID, existingArchive);
final Set<String> unchangedFilesToRestore = zipArchiveReader.readUnchangedDependentFiles();
final List<BackupInfo> dependencies = existingArchive.getBackupDependencies();
for (BackupInfo dependencyBackupInfo : dependencies)
{
restoreArchive(restoreDirectory, unchangedFilesToRestore, restoreConfig, backupable, dependencyBackupInfo);
}
}
// Restore the final archive file.
Set<String> filesToRestore = emptySet();
restoreArchive(restoreDirectory, filesToRestore, restoreConfig, backupable, existingArchive.getBackupInfo());
if (!restoreConfig.verifyOnly())
{
backupable.afterRestore(restoreDirectory, saveDirectory);
}
}
/**
* Removes the specified backup if it is possible to do so.
*
* @param backupDir The backup directory structure with which the
* specified backup is associated.
* @param backupID The backup ID for the backup to be removed.
*
* @throws DirectoryException If it is not possible to remove the specified
* backup for some reason (e.g., no such backup
* exists or there are other backups that are
* dependent upon it).
*/
public void removeBackup(BackupDirectory backupDir, String backupID) throws DirectoryException
{
ExistingBackupArchive archive = new ExistingBackupArchive(backupID, backupDir);
archive.removeArchive();
}
private Path getRestoreDirectory(Backupable backupable, String backupID)
{
File restoreDirectory = backupable.getDirectory();
if (!backupable.isDirectRestore())
{
restoreDirectory = new File(restoreDirectory.getAbsoluteFile() + "-restore-" + backupID);
}
return restoreDirectory.toPath();
}
private void closeArchiveWriter(BackupArchiveWriter archiveWriter, String backupFile, String backupPath)
throws DirectoryException
{
if (archiveWriter != null)
{
try
{
archiveWriter.close();
}
catch (Exception e)
{
logger.traceException(e);
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_CLOSE_ZIP_STREAM.get(backupFile, backupPath, stackTraceToSingleLineString(e)), e);
}
}
}
/**
* Restores the content of an archive file.
* <p>
* If set of files is not empty, only the specified files are restored.
* If set of files is empty, all files are restored.
*
* If the archive is being restored as a dependency, then only files in the
* specified set are restored, and the restored files are removed from the
* set. Otherwise all files from the archive are restored, and files that are
* to be found in dependencies are added to the set.
* @param restoreDir
* The directory in which files are to be restored.
* @param filesToRestore
* The set of files to restore. If empty, then all files are
* restored.
* @param restoreConfig
* The restore configuration.
* @param backupInfo
* The backup containing the files to be restored.
*
* @throws DirectoryException
* If a Directory Server error occurs.
* @throws IOException
* If an I/O exception occurs during the restore.
*/
private void restoreArchive(Path restoreDir,
Set<String> filesToRestore,
RestoreConfig restoreConfig,
Backupable backupable,
BackupInfo backupInfo) throws DirectoryException
{
String backupID = backupInfo.getBackupID();
String backupDirectoryPath = restoreConfig.getBackupDirectory().getPath();
BackupArchiveReader zipArchiveReader = new BackupArchiveReader(backupID, backupInfo, backupDirectoryPath);
zipArchiveReader.restoreArchive(restoreDir, filesToRestore, restoreConfig, backupable);
}
/** Retrieves the full path of the archive file. */
private static File retrieveArchiveFile(BackupInfo backupInfo, String backupDirectoryPath)
{
Map<String,String> backupProperties = backupInfo.getBackupProperties();
String archiveFilename = backupProperties.get(BACKUP_PROPERTY_ARCHIVE_FILENAME);
return new File(backupDirectoryPath, archiveFilename);
}
/**
* Get the information for a given backup ID from the backup directory.
*
* @param backupDir The backup directory.
* @param backupID The backup ID.
* @return The backup information, never null.
* @throws DirectoryException If the backup information cannot be found.
*/
private static BackupInfo getBackupInfo(BackupDirectory backupDir, String backupID) throws DirectoryException
{
BackupInfo backupInfo = backupDir.getBackupInfo(backupID);
if (backupInfo == null)
{
LocalizableMessage message = ERR_BACKUP_MISSING_BACKUPID.get(backupID, backupDir.getPath());
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), message);
}
return backupInfo;
}
/**
* Helper method to build a list of files to backup, in the simple case where all files are located
* under the provided directory.
*
* @param directory
* The directory containing files to backup.
* @param filter
* The filter to select files to backup.
* @param identifier
* Identifier of the backed-up entity
* @return the files to backup, which may be empty but never {@code null}
* @throws DirectoryException
* if an error occurs.
*/
public static List<Path> getFiles(File directory, FileFilter filter, String identifier)
throws DirectoryException
{
File[] files = null;
try
{
files = directory.listFiles(filter);
}
catch (Exception e)
{
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_LIST_LOG_FILES.get(directory.getAbsolutePath(), identifier), e);
}
if (files == null)
{
throw new DirectoryException(ResultCode.NO_SUCH_OBJECT,
ERR_BACKUP_CANNOT_LIST_LOG_FILES.get(directory.getAbsolutePath(), identifier));
}
List<Path> paths = new ArrayList<>();
for (File file : files)
{
paths.add(file.toPath());
}
return paths;
}
/**
* Helper method to save all current files of the provided backupable entity, using
* default behavior.
*
* @param backupable
* The entity to backup.
* @param identifier
* Identifier of the backup
* @return the directory where all files are saved.
* @throws DirectoryException
* If a problem occurs.
*/
public static Path saveCurrentFilesToDirectory(Backupable backupable, String identifier) throws DirectoryException
{
ListIterator<Path> filesToBackup = backupable.getFilesToBackup();
File rootDirectory = backupable.getDirectory();
String saveDirectory = rootDirectory.getAbsolutePath() + ".save";
BackupManager.saveFilesToDirectory(rootDirectory.toPath(), filesToBackup, saveDirectory, identifier);
return Paths.get(saveDirectory);
}
/**
* Helper method to move all provided files in a target directory created from
* provided target base path, keeping relative path information relative to
* root directory.
*
* @param rootDirectory
* A directory which is an ancestor of all provided files.
* @param files
* The files to move.
* @param targetBasePath
* Base path of the target directory. Actual directory is built by
* adding ".save" and a number, always ensuring that the directory is new.
* @param identifier
* Identifier of the backup
* @return the actual directory where all files are saved.
* @throws DirectoryException
* If a problem occurs.
*/
public static Path saveFilesToDirectory(Path rootDirectory, ListIterator<Path> files, String targetBasePath,
String identifier) throws DirectoryException
{
Path targetDirectory = null;
try
{
targetDirectory = createDirectoryWithNumericSuffix(targetBasePath, identifier);
while (files.hasNext())
{
Path file = files.next();
Path relativeFilePath = rootDirectory.relativize(file);
Path targetFile = targetDirectory.resolve(relativeFilePath);
Files.createDirectories(targetFile.getParent());
Files.move(file, targetFile);
}
return targetDirectory;
}
catch (IOException e)
{
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_SAVE_FILES_BEFORE_RESTORE.get(rootDirectory, targetDirectory, identifier,
stackTraceToSingleLineString(e)), e);
}
}
/**
* Creates a new directory based on the provided directory path, by adding a
* suffix number that is guaranteed to be the highest.
*/
static Path createDirectoryWithNumericSuffix(final String baseDirectoryPath, String identifier)
throws DirectoryException
{
try
{
int number = getHighestSuffixNumberForPath(baseDirectoryPath);
String path = baseDirectoryPath + (number + 1);
Path directory = Paths.get(path);
Files.createDirectories(directory);
return directory;
}
catch (IOException e)
{
throw new DirectoryException(DirectoryServer.getServerErrorResultCode(),
ERR_BACKUP_CANNOT_CREATE_SAVE_DIRECTORY.get(baseDirectoryPath, identifier,
stackTraceToSingleLineString(e)), e);
}
}
/**
* Returns a number that correspond to the highest suffix number existing for the provided base path.
* <p>
* Example: given the following directory structure
* <pre>
* +--- someDir
* | \--- directory
* | \--- directory1
* | \--- directory2
* | \--- directory10
* </pre>
* getHighestSuffixNumberForPath("directory") returns 10.
*
* @param basePath
* A base path to a file or directory, without any suffix number.
* @return the highest suffix number, or 0 if no suffix number exists
* @throws IOException
* if an error occurs.
*/
private static int getHighestSuffixNumberForPath(final String basePath) throws IOException
{
final File baseFile = new File(basePath).getCanonicalFile();
final File[] existingFiles = baseFile.getParentFile().listFiles();
final Pattern pattern = Pattern.compile(baseFile + "\\d*");
int highestNumber = 0;
for (File file : existingFiles)
{
final String name = file.getCanonicalPath();
if (pattern.matcher(name).matches())
{
String numberAsString = name.substring(baseFile.getPath().length());
int number = numberAsString.isEmpty() ? 0 : Integer.valueOf(numberAsString);
highestNumber = number > highestNumber ? number : highestNumber;
}
}
return highestNumber;
}
}