/* ==================================================================
* FileSystemBackupService.java - Mar 27, 2013 11:38:08 AM
*
* Copyright 2007-2013 SolarNetwork.net Dev Team
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
* 02111-1307 USA
* ==================================================================
*/
package net.solarnetwork.node.backup;
import static net.solarnetwork.node.backup.BackupStatus.Configured;
import static net.solarnetwork.node.backup.BackupStatus.Error;
import static net.solarnetwork.node.backup.BackupStatus.RunningBackup;
import static net.solarnetwork.node.backup.BackupStatus.Unconfigured;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Enumeration;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSource;
import org.springframework.util.FileCopyUtils;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.solarnetwork.node.Constants;
import net.solarnetwork.node.IdentityService;
import net.solarnetwork.node.settings.SettingSpecifier;
import net.solarnetwork.node.settings.SettingSpecifierProvider;
import net.solarnetwork.node.settings.support.BasicSliderSettingSpecifier;
import net.solarnetwork.node.settings.support.BasicTextFieldSettingSpecifier;
import net.solarnetwork.node.settings.support.BasicTitleSettingSpecifier;
import net.solarnetwork.util.OptionalService;
/**
* {@link BackupService} implementation that copies files to another location in
* the file system.
*
* <p>
* The configurable properties of this class are:
* </p>
*
* <dl class="class-properties">
* <dt>backupDir</dt>
* <dd>The directory to backup to.</dd>
*
* <dt>additionalBackupCount</dt>
* <dd>The number of additional backups to maintain. If greater than zero, then
* this service will maintain this many copies of past backups.
* </dl>
*
* @author matt
* @version 1.2
*/
public class FileSystemBackupService implements BackupService, SettingSpecifierProvider {
private static final String ARCHIVE_NAME_DATE_FORMAT = "yyyyMMdd'T'HHmmss";
/** The value returned by {@link #getKey()}. */
public static final String KEY = FileSystemBackupService.class.getName();
/**
* A format for turning a {@link Backup#getKey()} value into a zip file
* name.
*/
public static final String ARCHIVE_KEY_NAME_FORMAT = "node-%2$d-backup-%1$s.zip";
private static final String ARCHIVE_NAME_FORMAT = "node-%2$d-backup-%1$tY%1$tm%1$tdT%1$tH%1$tM%1$tS.zip";
private static final Pattern ARCHIVE_NAME_PAT = Pattern
.compile("node-(\\d+)-backup-(\\d{8}T\\d{6})\\.zip");
private final Logger log = LoggerFactory.getLogger(getClass());
private MessageSource messageSource;
private File backupDir = defaultBackuprDir();
private OptionalService<IdentityService> identityService;
private int additionalBackupCount = 1;
private BackupStatus status = Configured;
private static File defaultBackuprDir() {
String path = System.getProperty(Constants.SYSTEM_PROP_NODE_HOME, null);
if ( path == null ) {
path = System.getProperty("java.io.tmpdir");
} else {
if ( !path.endsWith("/") ) {
path += "/";
}
path += "var/backups";
}
return new File(path);
}
@Override
public String getSettingUID() {
return getClass().getName();
}
@Override
public String getDisplayName() {
return "File System Backup Service";
}
@Override
public MessageSource getMessageSource() {
return messageSource;
}
public void setMessageSource(MessageSource messageSource) {
this.messageSource = messageSource;
}
@Override
public List<SettingSpecifier> getSettingSpecifiers() {
List<SettingSpecifier> results = new ArrayList<SettingSpecifier>(20);
FileSystemBackupService defaults = new FileSystemBackupService();
results.add(new BasicTitleSettingSpecifier("status", getStatus().toString(), true));
results.add(new BasicTextFieldSettingSpecifier("backupDir",
defaults.getBackupDir().getAbsolutePath()));
results.add(new BasicSliderSettingSpecifier("additionalBackupCount",
(double) defaults.getAdditionalBackupCount(), 0.0, 10.0, 1.0));
return results;
}
@Override
public String getKey() {
return KEY;
}
@Override
public BackupServiceInfo getInfo() {
return new SimpleBackupServiceInfo(null, getStatus());
}
private String getArchiveKey(String archiveName) {
Matcher m = ARCHIVE_NAME_PAT.matcher(archiveName);
if ( m.matches() ) {
return m.group(2);
}
return archiveName;
}
@Override
public Backup backupForKey(String key) {
final File archiveFile = getArchiveFileForBackup(key);
if ( !archiveFile.canRead() ) {
return null;
}
return createBackupForFile(archiveFile, new SimpleDateFormat(ARCHIVE_NAME_DATE_FORMAT));
}
@Override
public Backup performBackup(final Iterable<BackupResource> resources) {
final Calendar now = new GregorianCalendar();
now.set(Calendar.MILLISECOND, 0);
return performBackupInternal(resources, now);
}
private Backup performBackupInternal(final Iterable<BackupResource> resources, final Calendar now) {
if ( resources == null ) {
return null;
}
final Iterator<BackupResource> itr = resources.iterator();
if ( !itr.hasNext() ) {
log.debug("No resources provided, nothing to backup");
return null;
}
BackupStatus status = setStatusIf(RunningBackup, Configured);
if ( status != RunningBackup ) {
// try to reset from error
status = setStatusIf(RunningBackup, Error);
if ( status != RunningBackup ) {
return null;
}
}
if ( !backupDir.exists() ) {
backupDir.mkdirs();
}
final Long nodeId = nodeIdForArchiveFileName();
final String archiveName = String.format(ARCHIVE_NAME_FORMAT, now, nodeId);
final File archiveFile = new File(backupDir, archiveName);
final String archiveKey = getArchiveKey(archiveName);
log.info("Starting backup to archive {}", archiveName);
log.trace("Backup archive: {}", archiveFile.getAbsolutePath());
Backup backup = null;
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(archiveFile)));
while ( itr.hasNext() ) {
BackupResource r = itr.next();
log.debug("Backup up resource {} to archive {}", r.getBackupPath(), archiveName);
zos.putNextEntry(new ZipEntry(r.getBackupPath()));
FileCopyUtils.copy(r.getInputStream(), new FilterOutputStream(zos) {
@Override
public void close() throws IOException {
// FileCopyUtils closes the stream, which we don't want
}
});
}
zos.flush();
zos.finish();
log.info("Backup complete to archive {}", archiveName);
backup = new SimpleBackup(now.getTime(), archiveKey, archiveFile.length(), true);
// clean out older backups
File[] backupFiles = getAvailableBackupFiles();
if ( backupFiles != null && backupFiles.length > additionalBackupCount + 1 ) {
// delete older files
for ( int i = additionalBackupCount + 1; i < backupFiles.length; i++ ) {
log.info("Deleting old backup archive {}", backupFiles[i].getName());
if ( !backupFiles[i].delete() ) {
log.warn("Unable to delete backup archive {}", backupFiles[i].getAbsolutePath());
}
}
}
} catch ( IOException e ) {
log.error("IO error creating backup: {}", e.getMessage());
setStatus(Error);
} catch ( RuntimeException e ) {
log.error("Error creating backup: {}", e.getMessage());
setStatus(Error);
} finally {
if ( zos != null ) {
try {
zos.close();
} catch ( IOException e ) {
// ignore this
}
}
status = setStatusIf(Configured, RunningBackup);
if ( status != Configured ) {
// clean up if we encountered an error
if ( archiveFile.exists() ) {
archiveFile.delete();
}
}
}
return backup;
}
private final Long nodeIdForArchiveFileName() {
IdentityService service = (identityService != null ? identityService.service() : null);
final Long nodeId = (service != null ? service.getNodeId() : null);
return (nodeId != null ? nodeId : 0L);
}
private File getArchiveFileForBackup(final String backupKey) {
final Long nodeId = nodeIdForArchiveFileName();
if ( nodeId.intValue() == 0 ) {
// hmm, might be restoring from corrupted db; look for file with matching key only
File[] matches = backupDir.listFiles(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return name.contains(backupKey);
}
});
if ( matches != null && matches.length > 0 ) {
// take first available
return matches[0];
}
// not found
return null;
} else {
return new File(backupDir, String.format(ARCHIVE_KEY_NAME_FORMAT, backupKey, nodeId));
}
}
@Override
public BackupResourceIterable getBackupResources(Backup backup) {
final File archiveFile = getArchiveFileForBackup(backup.getKey());
if ( !(archiveFile.isFile() && archiveFile.canRead()) ) {
log.warn("No backup archive exists for key [{}]", backup.getKey());
Collection<BackupResource> col = Collections.emptyList();
return new CollectionBackupResourceIterable(col);
}
try {
final ZipFile zf = new ZipFile(archiveFile);
Enumeration<? extends ZipEntry> entries = zf.entries();
List<BackupResource> result = new ArrayList<BackupResource>(20);
while ( entries.hasMoreElements() ) {
result.add(new ZipEntryBackupResource(zf, entries.nextElement()));
}
return new CollectionBackupResourceIterable(result) {
@Override
public void close() throws IOException {
zf.close();
}
};
} catch ( IOException e ) {
log.error("Error extracting backup archive entries: {}", e.getMessage());
}
Collection<BackupResource> col = Collections.emptyList();
return new CollectionBackupResourceIterable(col);
}
private Date backupDateFromProps(Date date, Map<String, String> props) {
if ( date != null ) {
return date;
}
final SimpleDateFormat sdf = new SimpleDateFormat(ARCHIVE_NAME_DATE_FORMAT);
String backupKey = (props == null ? null : props.get(BackupManager.BACKUP_KEY));
if ( backupKey != null ) {
Matcher m = ARCHIVE_NAME_PAT.matcher(backupKey);
if ( m.matches() ) {
try {
return sdf.parse(m.group(2));
} catch ( ParseException e ) {
log.warn("Unable to parse backup date from key [{}]", backupKey);
}
}
}
return new Date();
}
@Override
public Backup importBackup(Date date, BackupResourceIterable resources, Map<String, String> props) {
final Date backupDate = backupDateFromProps(date, props);
final Calendar cal = new GregorianCalendar();
cal.setTime(backupDate);
cal.set(Calendar.MILLISECOND, 0);
return performBackupInternal(resources, cal);
}
/**
* Delete any existing backups.
*/
public void removeAllBackups() {
File[] archives = backupDir.listFiles(new ArchiveFilter(nodeIdForArchiveFileName()));
if ( archives == null ) {
return;
}
for ( File archive : archives ) {
log.debug("Deleting backup archive {}", archive.getName());
if ( !archive.delete() ) {
log.warn("Unable to delete archive file {}", archive.getAbsolutePath());
}
}
}
/**
* Get all available backup files, ordered in desending backup order (newest
* to oldest).
*
* @return ordered array of backup files, or <em>null</em> if directory does
* not exist
*/
private File[] getAvailableBackupFiles() {
File[] archives = backupDir.listFiles(new ArchiveFilter(nodeIdForArchiveFileName()));
if ( archives != null ) {
Arrays.sort(archives, new Comparator<File>() {
@Override
public int compare(File o1, File o2) {
// sort in reverse order, so most recent backup first
return o2.getName().compareTo(o1.getName());
}
});
}
return archives;
}
private SimpleBackup createBackupForFile(File f, SimpleDateFormat sdf) {
Matcher m = ARCHIVE_NAME_PAT.matcher(f.getName());
if ( m.matches() ) {
try {
Date d = sdf.parse(m.group(2));
return new SimpleBackup(d, m.group(2), f.length(), true);
} catch ( ParseException e ) {
log.error("Error parsing date from archive " + f.getName() + ": " + e.getMessage());
}
}
return null;
}
@Override
public Collection<Backup> getAvailableBackups() {
File[] archives = getAvailableBackupFiles();
if ( archives == null ) {
return Collections.emptyList();
}
List<Backup> result = new ArrayList<Backup>(archives.length);
SimpleDateFormat sdf = new SimpleDateFormat(ARCHIVE_NAME_DATE_FORMAT);
for ( File f : archives ) {
SimpleBackup b = createBackupForFile(f, sdf);
if ( b != null ) {
result.add(b);
}
}
return result;
}
private File markedBackupForRestoreFile() {
// use default backup dir always, as configuration properties might not be available
return new File(defaultBackuprDir(), "RESTORE_ON_BOOT");
}
private ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return mapper;
}
private static final String MARKED_BACKUP_PROP_KEY = "key";
private static final String MARKED_BACKUP_PROP_PROPS = "props";
@Override
public synchronized boolean markBackupForRestore(Backup backup, Map<String, String> props) {
File markFile = markedBackupForRestoreFile();
if ( backup == null ) {
if ( markFile.exists() ) {
log.info("Clearing marked backup.");
return markFile.delete();
}
return true;
} else if ( markFile.exists() ) {
log.warn("Marked backup exists, will not mark again");
return false;
} else {
Map<String, Object> data = new HashMap<String, Object>();
data.put(MARKED_BACKUP_PROP_KEY, backup.getKey());
if ( props != null && !props.isEmpty() ) {
data.put(MARKED_BACKUP_PROP_PROPS, props);
}
try {
objectMapper().writeValue(markFile, data);
return true;
} catch ( IOException e ) {
log.warn("Failed to create restore mark file {}", markFile, e);
}
return false;
}
}
@Override
public synchronized Backup markedBackupForRestore(Map<String, String> props) {
File markFile = markedBackupForRestoreFile();
if ( markFile.exists() ) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> data = objectMapper().readValue(markFile, Map.class);
if ( data == null || !data.containsKey(MARKED_BACKUP_PROP_KEY) ) {
return null;
}
String key = (String) data.get(MARKED_BACKUP_PROP_KEY);
if ( props != null && data.get(MARKED_BACKUP_PROP_PROPS) instanceof Map ) {
@SuppressWarnings("unchecked")
Map<String, String> dataProps = (Map<String, String>) data
.get(MARKED_BACKUP_PROP_PROPS);
props.putAll(dataProps);
}
return backupForKey(key);
} catch ( IOException e ) {
log.warn("Failed to read restore mark file {}", markFile, e);
}
}
return null;
}
@Override
public SettingSpecifierProvider getSettingSpecifierProvider() {
return this;
}
private static class ArchiveFilter implements FilenameFilter {
final Long nodeId;
private ArchiveFilter(Long nodeId) {
super();
this.nodeId = nodeId;
}
@Override
public boolean accept(File dir, String name) {
Matcher m = ARCHIVE_NAME_PAT.matcher(name);
return (m.matches() && (nodeId == null || nodeId.equals(Long.valueOf(m.group(1)))));
}
}
private BackupStatus getStatus() {
synchronized ( status ) {
if ( backupDir == null ) {
return Unconfigured;
}
if ( !backupDir.exists() ) {
if ( !backupDir.mkdirs() ) {
log.warn("Could not create backup dir {}", backupDir.getAbsolutePath());
return Unconfigured;
}
}
if ( !backupDir.isDirectory() ) {
log.error("Configured backup location is not a directory: {}",
backupDir.getAbsolutePath());
return Unconfigured;
}
return status;
}
}
private void setStatus(BackupStatus newStatus) {
synchronized ( status ) {
status = newStatus;
}
}
private BackupStatus setStatusIf(BackupStatus newStatus, BackupStatus ifStatus) {
synchronized ( status ) {
if ( status == ifStatus ) {
status = newStatus;
}
return status;
}
}
public File getBackupDir() {
return backupDir;
}
public void setBackupDir(File backupDir) {
this.backupDir = backupDir;
}
public int getAdditionalBackupCount() {
return additionalBackupCount;
}
public void setAdditionalBackupCount(int additionalBackupCount) {
this.additionalBackupCount = additionalBackupCount;
}
public OptionalService<IdentityService> getIdentityService() {
return identityService;
}
public void setIdentityService(OptionalService<IdentityService> identityService) {
this.identityService = identityService;
}
}