/*
* The MIT License
*
* Copyright 2013 Mirko Friedenhagen.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package hudson.plugins.jobConfigHistory;
import static java.util.logging.Level.FINEST;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.io.FileUtils;
import hudson.Extension;
import hudson.FilePath;
import hudson.Util;
import hudson.XmlFile;
import hudson.maven.MavenModule;
import hudson.model.AbstractItem;
import hudson.model.Item;
import hudson.model.Node;
import hudson.model.User;
import jenkins.model.Jenkins;
/**
* Defines some helper functions needed by {@link JobConfigHistoryJobListener}
* and {@link JobConfigHistorySaveableListener}.
*
* @author Mirko Friedenhagen
*/
@Extension
public class FileHistoryDao extends JobConfigHistoryStrategy
implements
Purgeable {
/** Our logger. */
private static final Logger LOG = Logger
.getLogger(FileHistoryDao.class.getName());
/** milliseconds between attempts to save a new entry. */
private static final int CLASH_SLEEP_TIME = 500;
/** Base location for all files. */
private final File historyRootDir;
/** JENKINS_HOME. */
private final File jenkinsHome;
/** Currently logged in user. */
private final User currentUser;
/** Maximum numbers which should exist. */
private final int maxHistoryEntries;
/** Should we save duplicate entries? */
private final boolean saveDuplicates;
public FileHistoryDao() {
this(null, null, null, 0, false);
}
/**
* @param historyRootDir
* where to store history
* @param jenkinsHome
* JENKKINS_HOME
* @param currentUser
* of operation
* @param maxHistoryEntries
* max number of history entries
* @param saveDuplicates
* should we save duplicate entries?
*/
public FileHistoryDao(final File historyRootDir, final File jenkinsHome,
final User currentUser, final int maxHistoryEntries,
final boolean saveDuplicates) {
this.historyRootDir = historyRootDir;
this.jenkinsHome = jenkinsHome;
this.currentUser = currentUser;
this.maxHistoryEntries = maxHistoryEntries;
this.saveDuplicates = saveDuplicates;
}
/**
* Creates a timestamped directory to save the configuration beneath. Purges
* old data if configured
*
* @param xmlFile
* the current xmlFile configuration file to save
* @param timestampHolder
* time of operation.
* @return timestamped directory where to store one history entry.
*/
File getRootDir(final XmlFile xmlFile,
final AtomicReference<Calendar> timestampHolder) {
final File configFile = xmlFile.getFile();
final File itemHistoryDir = getHistoryDir(configFile);
// perform check for purge here, when we are actually going to create
// a new directory, rather than just when we scan it in above method.
purgeOldEntries(itemHistoryDir, maxHistoryEntries);
return createNewHistoryDir(itemHistoryDir, timestampHolder);
}
/**
* Creates the historical description for this action.
*
* @param timestamp
* when the action did happen.
* @param timestampedDir
* the directory where to save the history.
* @param operation
* description of operation.
* @throws IOException
* if writing the history fails.
*/
void createHistoryXmlFile(final Calendar timestamp,
final File timestampedDir, final String operation,
final String newName, String oldName) throws IOException {
oldName = ((oldName == null) ? "" : oldName);
final String user;
final String userId;
if (currentUser != null) {
user = currentUser.getFullName();
userId = currentUser.getId();
} else {
user = "Anonym";
userId = Messages.ConfigHistoryListenerHelper_anonymous();
}
final XmlFile historyDescription = getHistoryXmlFile(timestampedDir);
final HistoryDescr myDescr = new HistoryDescr(user, userId, operation,
getIdFormatter().format(timestamp.getTime()),
(newName == null) ? "" : newName, (newName == null)
? ""
: ((newName.equals(oldName)) ? "" : oldName));
historyDescription.write(myDescr);
}
/**
* Returns the history.xml file in the directory.
*
* @param directory
* to search.
*
* @return history.xml
*/
private XmlFile getHistoryXmlFile(final File directory) {
return new XmlFile(
new File(directory, JobConfigHistoryConsts.HISTORY_FILE));
}
/**
* Saves a copy of this project's {@literal config.xml} into
* {@literal timestampedDir}.
*
* @param currentConfig
* which we want to copy.
* @param timestampedDir
* the directory where to save the copy.
* @throws FileNotFoundException
* if initiating the file holding the copy fails.
* @throws IOException
* if writing the file holding the copy fails.
*/
static void copyConfigFile(final File currentConfig,
final File timestampedDir)
throws FileNotFoundException, IOException {
final BufferedOutputStream configCopy = new BufferedOutputStream(
new FileOutputStream(
new File(timestampedDir, currentConfig.getName())));
try {
final FileInputStream configOriginal = new FileInputStream(
currentConfig);
try {
// in is buffered by copyStream.
Util.copyStream(configOriginal, configCopy);
} finally {
configOriginal.close();
}
} finally {
configCopy.close();
}
}
/**
* Returns a simple formatter used for creating timestamped directories. We
* create this every time as {@link SimpleDateFormat} is <b>not</b>
* threadsafe.
*
* @return the idFormatter
*/
static SimpleDateFormat getIdFormatter() {
return new SimpleDateFormat(JobConfigHistoryConsts.ID_FORMATTER);
}
/**
* Creates the new history dir, loops until "enough" time has passed if two
* events are too near.
*
* @param itemHistoryDir
* the basedir for history items.
* @param timestampHolder
* of the event.
* @return new directory.
*/
@SuppressWarnings("SleepWhileInLoop")
static File createNewHistoryDir(final File itemHistoryDir,
final AtomicReference<Calendar> timestampHolder) {
Calendar timestamp;
File f;
while (true) {
timestamp = new GregorianCalendar();
f = new File(itemHistoryDir,
getIdFormatter().format(timestamp.getTime()));
if (f.isDirectory()) {
LOG.log(Level.FINE, "clash on {0}, will wait a moment", f);
try {
Thread.sleep(CLASH_SLEEP_TIME);
} catch (InterruptedException x) {
throw new RuntimeException(x);
}
} else {
timestampHolder.set(timestamp);
break;
}
}
// mkdirs sometimes fails although the directory exists afterwards,
// so check for existence as well and just be happy if it does.
if (!(f.mkdirs() || f.exists())) {
throw new RuntimeException("Could not create rootDir " + f);
}
return f;
}
@Override
public void createNewItem(final Item item) {
final AbstractItem aItem = (AbstractItem) item;
createNewHistoryEntryAndCopyConfig(aItem.getConfigFile(),
Messages.ConfigHistoryListenerHelper_CREATED(), null, null);
}
/**
* Creates a new history entry and copies the old config.xml to a
* timestamped dir.
*
* @param configFile
* to copy.
* @param operation
* operation
*/
private void createNewHistoryEntryAndCopyConfig(final XmlFile configFile,
final String operation, final String newName,
final String oldName) {
final File timestampedDir = createNewHistoryEntry(configFile, operation,
newName, oldName);
try {
copyConfigFile(configFile.getFile(), timestampedDir);
} catch (IOException ex) {
throw new RuntimeException("Unable to copy " + configFile, ex);
}
}
@Override
public void saveItem(final XmlFile file) {
if (checkDuplicate(file)) {
createNewHistoryEntryAndCopyConfig(file,
Messages.ConfigHistoryListenerHelper_CHANGED(), null, null);
}
}
@Override
public void deleteItem(final Item item) {
final AbstractItem aItem = (AbstractItem) item;
createNewHistoryEntry(aItem.getConfigFile(),
Messages.ConfigHistoryListenerHelper_DELETED(), null, null);
final File configFile = aItem.getConfigFile().getFile();
final File currentHistoryDir = getHistoryDir(configFile);
final SimpleDateFormat buildDateFormat = new SimpleDateFormat(
"yyyyMMdd_HHmmss_SSS");
final String timestamp = buildDateFormat.format(new Date());
final String deletedHistoryName = item.getName()
+ DeletedFileFilter.DELETED_MARKER + timestamp;
final File deletedHistoryDir = new File(
currentHistoryDir.getParentFile(), deletedHistoryName);
if (!currentHistoryDir.renameTo(deletedHistoryDir)) {
LOG.log(Level.WARNING,
"unable to rename deleted history dir to: {0}",
deletedHistoryDir);
}
}
@Override
public void renameItem(final Item item, final String oldName,
final String newName) {
final AbstractItem aItem = (AbstractItem) item;
final String onRenameDesc = " old name: " + oldName + ", new name: "
+ newName;
if (historyRootDir != null) {
final File configFile = aItem.getConfigFile().getFile();
final File currentHistoryDir = getHistoryDir(configFile);
final File historyParentDir = currentHistoryDir.getParentFile();
final File oldHistoryDir = new File(historyParentDir, oldName);
if (oldHistoryDir.exists()) {
final FilePath fp = new FilePath(oldHistoryDir);
// catch all exceptions so Jenkins can continue with other
// rename
// tasks.
try {
fp.copyRecursiveTo(new FilePath(currentHistoryDir));
fp.deleteRecursive();
LOG.log(FINEST,
"completed move of old history files on rename.{0}",
onRenameDesc);
} catch (IOException e) {
final String ioExceptionStr = "unable to move old history on rename."
+ onRenameDesc;
LOG.log(Level.SEVERE, ioExceptionStr, e);
} catch (InterruptedException e) {
final String irExceptionStr = "interrupted while moving old history on rename."
+ onRenameDesc;
LOG.log(Level.WARNING, irExceptionStr, e);
}
}
}
createNewHistoryEntryAndCopyConfig(aItem.getConfigFile(),
Messages.ConfigHistoryListenerHelper_RENAMED(), newName,
oldName);
}
@Override
public SortedMap<String, HistoryDescr> getRevisions(final XmlFile xmlFile) {
return getRevisions(xmlFile.getFile());
}
private SortedMap<String, HistoryDescr> getRevisions(
final File configFile) {
final File historiesDir = getHistoryDir(configFile);
return getRevisions(historiesDir, configFile);
}
/**
* Returns a sorted map of all revisions for this configFile.
*
* @param historiesDir
* to search.
* @param configFile
* for exception
* @return sorted map
*/
private SortedMap<String, HistoryDescr> getRevisions(
final File historiesDir, final File configFile) {
final File[] historyDirsOfItem = historiesDir
.listFiles(HistoryFileFilter.INSTANCE);
final TreeMap<String, HistoryDescr> map = new TreeMap<String, HistoryDescr>();
if (historyDirsOfItem == null) {
return map;
} else {
for (File historyDir : historyDirsOfItem) {
final XmlFile historyXml = getHistoryXmlFile(historyDir);
final LazyHistoryDescr historyDescription = new LazyHistoryDescr(
historyXml);
map.put(historyDir.getName(), historyDescription);
}
return map;
}
}
@Override
public XmlFile getOldRevision(final AbstractItem item,
final String identifier) {
final File configFile = item.getConfigFile().getFile();
final File historyDir = new File(getHistoryDir(configFile), identifier);
if (PluginUtils.isMavenPluginAvailable()
&& item instanceof MavenModule) {
final String path = historyDir
+ ((MavenModule) item).getParent().getFullName()
.replace("/", "/jobs/")
+ "/modules/"
+ ((MavenModule) item).getModuleName().toFileSystemName()
+ "/" + identifier;
return new XmlFile(getConfigFile(new File(path)));
} else {
return new XmlFile(getConfigFile(historyDir));
}
}
@Override
public XmlFile getOldRevision(final XmlFile xmlFile,
final String identifier) {
final File configFile = xmlFile.getFile();
return getOldRevision(configFile, identifier);
}
private XmlFile getOldRevision(final File configFile,
final String identifier) {
final File historyDir = new File(getHistoryDir(configFile), identifier);
return new XmlFile(getConfigFile(historyDir));
}
@Override
public XmlFile getOldRevision(final String configFileName,
final String identifier) {
final File historyDir = new File(
new File(historyRootDir, configFileName), identifier);
final File configFile = getConfigFile(historyDir);
if (configFile == null) {
throw new IllegalArgumentException("Could not find " + historyDir);
}
return new XmlFile(configFile);
}
@Override
public boolean hasOldRevision(final XmlFile xmlFile,
final String identifier) {
final File configFile = xmlFile.getFile();
final XmlFile oldRevision = getOldRevision(configFile, identifier);
return oldRevision.getFile() != null && oldRevision.getFile().exists();
}
/**
* Creates a new history entry.
*
* @param xmlFile
* to save.
* @param operation
* description
*
* @return timestampedDir
*/
File createNewHistoryEntry(final XmlFile xmlFile, final String operation,
final String newName, final String oldName) {
try {
final AtomicReference<Calendar> timestampHolder = new AtomicReference<Calendar>();
final File timestampedDir = getRootDir(xmlFile, timestampHolder);
LOG.log(Level.FINE, "{0} on {1}",
new Object[]{this, timestampedDir});
createHistoryXmlFile(timestampHolder.get(), timestampedDir,
operation, newName, oldName);
assert timestampHolder.get() != null;
return timestampedDir;
} catch (IOException e) {
// If not able to create the history entry, log, but continue
// without it.
// A known issue is where Jenkins core fails to move the folders on
// rename,
// but continues as if it did.
// Reference https://issues.jenkins-ci.org/browse/JENKINS-8318
throw new RuntimeException(
"Unable to create history entry for configuration file: "
+ xmlFile.getFile().getAbsolutePath(),
e);
}
}
/**
* Returns the configuration history directory for the given configuration
* file.
*
* @param configFile
* The configuration file whose content we are saving.
* @return The base directory where to store the history, or null if the
* file is not a valid Jenkins configuration file.
*/
public File getHistoryDir(final File configFile) {
final String configRootDir = configFile.getParent();
final String jenkinsRootDir = jenkinsHome.getPath();
if (!configRootDir.startsWith(jenkinsRootDir)) {
throw new IllegalArgumentException(
"Trying to get history dir for object outside of Jenkins: "
+ configFile);
}
// if the file is stored directly under JENKINS_ROOT, it's a system
// config
// so create a distinct directory
String underRootDir = null;
if (configRootDir.equals(jenkinsRootDir)) {
final String fileName = configFile.getName();
underRootDir = fileName.substring(0, fileName.lastIndexOf('.'));
}
final File historyDir;
if (underRootDir == null) {
final String remainingPath = configRootDir
.substring(jenkinsRootDir.length()
+ JobConfigHistoryConsts.JOBS_HISTORY_DIR.length()
+ 1);
historyDir = new File(getJobHistoryRootDir(), remainingPath);
} else {
historyDir = new File(historyRootDir, underRootDir);
}
return historyDir;
}
/**
* Returns the File object representing the job history directory, which is
* for reasons of backwards compatibility either a sibling or child of the
* configured history root dir.
*
* @return The job history File object.
*/
File getJobHistoryRootDir() {
// ROOT/config-history/jobs
return new File(historyRootDir,
"/" + JobConfigHistoryConsts.JOBS_HISTORY_DIR);
}
@Override
public void purgeOldEntries(final File itemHistoryRoot,
final int maxEntries) {
if (maxEntries > 0) {
LOG.log(Level.FINE,
"checking for history files to purge ({0} max allowed)",
maxEntries);
final int entriesToLeave = maxEntries - 1;
final File[] historyDirs = itemHistoryRoot
.listFiles(HistoryFileFilter.INSTANCE);
if (historyDirs != null && historyDirs.length >= entriesToLeave) {
Arrays.sort(historyDirs, Collections.reverseOrder());
for (int i = entriesToLeave; i < historyDirs.length; i++) {
if (isCreatedEntry(historyDirs[i])) {
continue;
}
LOG.log(Level.FINE,
"purging old directory from history logs: {0}",
historyDirs[i]);
deleteDirectory(historyDirs[i]);
}
}
}
}
@Override
public boolean isCreatedEntry(final File historyDir) {
final XmlFile historyXml = getHistoryXmlFile(historyDir);
try {
final HistoryDescr histDescr = (HistoryDescr) historyXml.read();
LOG.log(Level.FINEST, "historyDir: {0}", historyDir);
LOG.log(Level.FINEST, "histDescr.getOperation(): {0}",
histDescr.getOperation());
if ("Created".equals(histDescr.getOperation())) {
return true;
}
} catch (IOException ex) {
LOG.log(Level.FINEST, "Unable to retrieve history file for {0}",
historyDir);
}
return false;
}
/**
* Deletes a history directory (e.g. Test/2013-18-01_19-53-40), first
* deleting the files it contains.
*
* @param dir
* The directory which should be deleted.
*/
private void deleteDirectory(final File dir) {
try {
for (File file : dir.listFiles()) {
if (!file.delete()) {
LOG.log(Level.WARNING, "problem deleting history file: {0}",
file);
}
}
if (!dir.delete()) {
LOG.log(Level.WARNING,
"problem deleting history directory: {0}", dir);
}
} catch (NullPointerException e) {
LOG.log(Level.WARNING, "Directory already deleted or null. ", e);
}
}
/**
* Returns the configuration data file stored in the specified history
* directory. It looks for a file with an 'xml' extension that is not named
* {@link JobConfigHistoryConsts#HISTORY_FILE}.
* <p>
* Relies on the assumption that random '.xml' files will not appear in the
* history directories.
* <p>
* Checks that we are in an actual 'history directory' to prevent use for
* getting random xml files.
*
* @param historyDir
* The history directory to look under.
* @return The configuration file or null if no file is found.
*/
public static File getConfigFile(final File historyDir) {
File configFile = null;
if (HistoryFileFilter.accepts(historyDir)) {
// get the *.xml file that is not the
// JobConfigHistoryConsts.HISTORY_FILE
// assumes random .xml files won't appear in the history directory
try {
final File[] listing = historyDir.listFiles();
for (final File file : listing) {
if (!file.getName()
.equals(JobConfigHistoryConsts.HISTORY_FILE)
&& file.getName().matches(".*\\.xml$")) {
configFile = file;
}
}
} catch (NullPointerException e) {
LOG.log(Level.WARNING, "History dir is null. ", e);
}
}
return configFile;
}
/**
* Determines if the {@link XmlFile} contains a duplicate of the last saved
* information, if there is previous history.
*
* @param xmlFile
* The {@link XmlFile} configuration file under consideration.
* @return true if previous history is accessible, and the file duplicates
* the previously saved information.
*/
boolean hasDuplicateHistory(final XmlFile xmlFile) {
boolean isDuplicated = false;
final ArrayList<String> timeStamps = new ArrayList<String>(
getRevisions(xmlFile).keySet());
if (!timeStamps.isEmpty()) {
Collections.sort(timeStamps, Collections.reverseOrder());
final XmlFile lastRevision = getOldRevision(xmlFile,
timeStamps.get(0));
try {
if (xmlFile.asString().equals(lastRevision.asString())) {
isDuplicated = true;
}
} catch (IOException e) {
LOG.log(Level.WARNING,
"unable to check for duplicate previous history file: {0}\n{1}",
new Object[]{lastRevision, e});
}
}
return isDuplicated;
}
/**
* Checks whether the configuration file should not be saved because it's a
* duplicate.
*
* @param xmlFile
* The config file
* @return True if it should be saved
*/
boolean checkDuplicate(final XmlFile xmlFile) {
if (!saveDuplicates && hasDuplicateHistory(xmlFile)) {
LOG.log(Level.FINE, "found duplicate history, skipping save of {0}",
xmlFile);
return false;
} else {
return true;
}
}
@Override
public File[] getDeletedJobs(final String folderName) {
return returnEmptyFileArrayForNull(
getJobDirectoryIncludingFolder(folderName)
.listFiles(DeletedFileFilter.INSTANCE));
}
@Override
public File[] getJobs(final String folderName) {
return returnEmptyFileArrayForNull(
getJobDirectoryIncludingFolder(folderName)
.listFiles(NonDeletedFileFilter.INSTANCE));
}
/**
* Returns the history directory for a job in a folder.
*
* @param folderName
* name of the folder.
* @return history directory for a job in a folder.
*/
private File getJobDirectoryIncludingFolder(final String folderName) {
final String realFolderName = folderName.isEmpty()
? folderName
: folderName + "/jobs";
return new File(getJobHistoryRootDir(), realFolderName);
}
@Override
public File[] getSystemConfigs() {
return returnEmptyFileArrayForNull(
historyRootDir.listFiles(NonJobsDirectoryFileFilter.INSTANCE));
}
/**
* Returns an empty array when array is null.
*
* @param array
* file array.
* @return an empty array when array is null.
*/
private File[] returnEmptyFileArrayForNull(final File[] array) {
if (array != null) {
return array;
} else {
return new File[0];
}
}
@Override
public SortedMap<String, HistoryDescr> getJobHistory(final String jobName) {
return getRevisions(new File(getJobHistoryRootDir(), jobName),
new File(jobName));
}
@Override
public SortedMap<String, HistoryDescr> getSystemHistory(final String name) {
return getRevisions(new File(historyRootDir, name), new File(name));
}
@Deprecated
public void copyHistoryAndDelete(final String oldName,
final String newName) {
final File oldFile = new File(getJobHistoryRootDir(), oldName);
final File newFile = new File(getJobHistoryRootDir(), newName);
try {
FileUtils.copyDirectory(oldFile, newFile);
FileUtils.deleteDirectory(oldFile);
} catch (IOException ex) {
throw new IllegalArgumentException(
"Unable to move from " + oldFile + " to " + newFile, ex);
}
}
@Override
public void createNewNode(final Node node) {
final String content = Jenkins.XSTREAM2.toXML(node);
createNewHistoryEntryAndSaveConfig(node, content,
Messages.ConfigHistoryListenerHelper_CREATED(), null, null);
}
/**
* Creates a new history entry and saves the slave configuration.
*
* @param node
* node.
* @param content
* content.
* @param operation
* operation.
*/
private void createNewHistoryEntryAndSaveConfig(final Node node,
final String content, final String operation, final String newName,
final String oldName) {
// TODO:
final File timestampedDir = createNewHistoryEntry(node, operation,
newName, oldName);
final File nodeConfigHistoryFile = new File(timestampedDir,
"config.xml");
PrintStream stream = null;
try {
stream = new PrintStream(nodeConfigHistoryFile, "UTF-8");
stream.print(content);
} catch (IOException ex) {
throw new RuntimeException(
"Unable to write " + nodeConfigHistoryFile, ex);
} finally {
if (stream != null) {
stream.close();
}
}
}
@Override
public void deleteNode(final Node node) {
createNewHistoryEntry(node,
Messages.ConfigHistoryListenerHelper_DELETED(), null, null);
// final File configFile = aItem.getConfigFile().getFile();
final File currentHistoryDir = getHistoryDirForNode(node);
final SimpleDateFormat buildDateFormat = new SimpleDateFormat(
"yyyyMMdd_HHmmss_SSS");
final String timestamp = buildDateFormat.format(new Date());
final String deletedHistoryName = node.getNodeName()
+ DeletedFileFilter.DELETED_MARKER + timestamp;
final File deletedHistoryDir = new File(
currentHistoryDir.getParentFile(), deletedHistoryName);
if (!currentHistoryDir.renameTo(deletedHistoryDir)) {
LOG.log(Level.WARNING,
"unable to rename deleted history dir to: {0}",
deletedHistoryDir);
}
}
@Override
public void renameNode(final Node node, final String oldName,
final String newName) {
final String onRenameDesc = " old name: " + oldName + ", new name: "
+ newName;
if (historyRootDir != null) {
// final File configFile = aItem.getConfigFile().getSlaveFile();
final File currentHistoryDir = getHistoryDirForNode(node);
final File historyParentDir = currentHistoryDir.getParentFile();
final File oldHistoryDir = new File(historyParentDir, oldName);
if (oldHistoryDir.exists()) {
final FilePath fp = new FilePath(oldHistoryDir);
// catch all exceptions so Jenkins can continue with other
// rename
// tasks.
try {
fp.copyRecursiveTo(new FilePath(currentHistoryDir));
fp.deleteRecursive();
LOG.log(Level.FINEST,
"completed move of old history files on rename.{0}",
onRenameDesc);
} catch (IOException e) {
final String ioExceptionStr = "unable to move old history on rename."
+ onRenameDesc;
LOG.log(Level.SEVERE, ioExceptionStr, e);
} catch (InterruptedException e) {
final String irExceptionStr = "interrupted while moving old history on rename."
+ onRenameDesc;
LOG.log(Level.WARNING, irExceptionStr, e);
}
}
}
final String content = Jenkins.XSTREAM2.toXML(node);
// TODO:
createNewHistoryEntryAndSaveConfig(node, content,
Messages.ConfigHistoryListenerHelper_RENAMED(), newName,
oldName);
}
@Override
public SortedMap<String, HistoryDescr> getRevisions(final Node node) {
final File historiesDir = getHistoryDirForNode(node);
final File[] historyDirsOfItem = historiesDir
.listFiles(HistoryFileFilter.INSTANCE);
final TreeMap<String, HistoryDescr> map = new TreeMap<String, HistoryDescr>();
if (historyDirsOfItem == null) {
return map;
} else {
for (File historyDir : historyDirsOfItem) {
final XmlFile historyXml = getHistoryXmlFile(historyDir);
final HistoryDescr historyDescription;
try {
historyDescription = (HistoryDescr) historyXml.read();
} catch (IOException ex) {
throw new RuntimeException("Unable to read history for "
+ node.getDisplayName(), ex);
}
map.put(historyDir.getName(), historyDescription);
}
return map;
}
}
private File getRootDir(final Node node,
final AtomicReference<Calendar> timestampHolder) {
final File itemHistoryDir = getHistoryDirForNode(node);
// perform check for purge here, when we are actually going to create
// a new directory, rather than just when we scan it in above method.
purgeOldEntries(itemHistoryDir, maxHistoryEntries);
return createNewHistoryDir(itemHistoryDir, timestampHolder);
}
private File createNewHistoryEntry(final Node node, final String operation,
final String newName, final String oldName) {
try {
final AtomicReference<Calendar> timestampHolder = new AtomicReference<Calendar>();
final File timestampedDir = getRootDir(node, timestampHolder);
LOG.log(Level.FINE, "{0} on {1}",
new Object[]{this, timestampedDir});
createHistoryXmlFile(timestampHolder.get(), timestampedDir,
operation, newName, oldName);
assert timestampHolder.get() != null;
return timestampedDir;
} catch (IOException e) {
// If not able to create the history entry, log, but continue
// without it.
// A known issue is where Jenkins core fails to move the folders on
// rename,
// but continues as if it did.
// Reference https://issues.jenkins-ci.org/browse/JENKINS-8318
throw new RuntimeException(
"Unable to create history entry for configuration file of node "
+ node.getDisplayName(),
e);
}
}
/**
* Returns the configuration history directory for the given configuration
* file.
*
* @param node
* node
* @return The base directory where to store the history, or null if the
* file is not a valid Jenkins configuration file.
*/
private File getHistoryDirForNode(final Node node) {
final String name = node.getNodeName();
final File configHistoryDir = getNodeHistoryRootDir();
final File configHistoryNodeDir = new File(configHistoryDir, name);
return configHistoryNodeDir;
}
File getNodeHistoryRootDir() {
return new File(historyRootDir,
"/" + JobConfigHistoryConsts.NODES_HISTORY_DIR);
}
/** {@inheritDoc} */
@Override
public boolean hasDuplicateHistory(final Node node) {
final String content = Jenkins.XSTREAM2.toXML(node);
boolean isDuplicated = false;
final ArrayList<String> timeStamps = new ArrayList<String>(
getRevisions(node).keySet());
if (!timeStamps.isEmpty()) {
Collections.sort(timeStamps, Collections.reverseOrder());
final XmlFile lastRevision = getOldRevision(node,
timeStamps.get(0));
try {
if (content.equals(lastRevision.asString())) {
isDuplicated = true;
}
} catch (IOException e) {
LOG.log(Level.WARNING,
"unable to check for duplicate previous history file: {0}\n{1}",
new Object[]{lastRevision, e});
}
}
return isDuplicated;
}
/**
* Check if it is a duplicate.
*
* @param node
* node
* @return true if it is a duplicate
*/
private boolean checkDuplicate(final Node node) {
if (!saveDuplicates && hasDuplicateHistory(node)) {
LOG.log(Level.FINE, "found duplicate history, skipping save of {0}",
node.getDisplayName());
return false;
} else {
return true;
}
}
@Override
public void saveNode(final Node node) {
final String content = Jenkins.XSTREAM2.toXML(node);
if (checkDuplicate(node)) {
createNewHistoryEntryAndSaveConfig(node, content,
Messages.ConfigHistoryListenerHelper_CHANGED(), null, null);
}
}
@Override
public XmlFile getOldRevision(final Node node, final String identifier) {
final File historyDir = new File(getHistoryDirForNode(node),
identifier);
return new XmlFile(getConfigFile(historyDir));
}
@Override
public boolean hasOldRevision(final Node node, final String identifier) {
final XmlFile oldRevision = getOldRevision(node, identifier);
return oldRevision.getFile() != null && oldRevision.getFile().exists();
}
}