Java tutorial
/* * 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 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 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 static java.util.logging.Level.FINEST; import java.util.logging.Logger; import jenkins.model.Jenkins; import org.apache.commons.io.FileUtils; /** * Defines some helper functions needed by {@link JobConfigHistoryJobListener} and * {@link JobConfigHistorySaveableListener}. * * @author mfriedenhagen */ public class FileHistoryDao implements HistoryDao, ItemListenerHistoryDao, OverviewHistoryDao, NodeListenerHistoryDao { /** 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; /** * @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? */ FileHistoryDao(final File historyRootDir, File jenkinsHome, User currentUser, int maxHistoryEntries, 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) throws IOException { 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())); 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(Item item) { final AbstractItem aItem = (AbstractItem) item; createNewHistoryEntryAndCopyConfig(aItem.getConfigFile(), Messages.ConfigHistoryListenerHelper_CREATED()); } /** * Creates a new history entry and copies the old config.xml to a timestamped dir. * * @param configFile to copy. * @param operation operation */ void createNewHistoryEntryAndCopyConfig(final XmlFile configFile, final String operation) { final File timestampedDir = createNewHistoryEntry(configFile, operation); try { copyConfigFile(configFile.getFile(), timestampedDir); } catch (IOException ex) { throw new RuntimeException("Unable to copy " + configFile, ex); } } @Override public void saveItem(AbstractItem item) { saveItem(item.getConfigFile()); } @Override public void saveItem(XmlFile file) { if (checkDuplicate(file)) { createNewHistoryEntryAndCopyConfig(file, Messages.ConfigHistoryListenerHelper_CHANGED()); } } @Override public void deleteItem(Item item) { final AbstractItem aItem = (AbstractItem) item; createNewHistoryEntry(aItem.getConfigFile(), Messages.ConfigHistoryListenerHelper_DELETED()); 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() + JobConfigHistoryConsts.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(Item item, String oldName, 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 Hudson 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()); } @Override public SortedMap<String, HistoryDescr> getRevisions(XmlFile xmlFile) { return getRevisions(xmlFile.getFile()); } @Override public SortedMap<String, HistoryDescr> getRevisions(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, 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(AbstractItem item, String identifier) { final File configFile = item.getConfigFile().getFile(); final File historyDir = new File(getHistoryDir(configFile), identifier); if (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(XmlFile xmlFile, String identifier) { final File configFile = xmlFile.getFile(); return getOldRevision(configFile, identifier); } @Override public XmlFile getOldRevision(File configFile, String identifier) { final File historyDir = new File(getHistoryDir(configFile), identifier); return new XmlFile(getConfigFile(historyDir)); } @Override public XmlFile getOldRevision(String configFileName, 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(AbstractItem item, String identifier) { return hasOldRevision(item.getConfigFile(), identifier); } @Override public boolean hasOldRevision(XmlFile xmlFile, String identifier) { return hasOldRevision(xmlFile.getFile(), identifier); } @Override public boolean hasOldRevision(File configFile, String identifier) { 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) { 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); 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 Hudson 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 Hudson configuration file. */ File getHistoryDir(final File configFile) { final String configRootDir = configFile.getParent(); final String hudsonRootDir = jenkinsHome.getPath(); if (!configRootDir.startsWith(hudsonRootDir)) { throw new IllegalArgumentException( "Trying to get history dir for object outside of HUDSON: " + configFile); } //if the file is stored directly under HUDSON_ROOT, it's a system config //so create a distinct directory String underRootDir = null; if (configRootDir.equals(hudsonRootDir)) { final String fileName = configFile.getName(); underRootDir = fileName.substring(0, fileName.lastIndexOf('.')); } final File historyDir; if (underRootDir == null) { final String remainingPath = configRootDir .substring(hudsonRootDir.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(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(File dir) { 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); } } /** * 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. */ 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 final File[] listing = historyDir.listFiles(); for (final File file : listing) { if (!file.getName().equals(JobConfigHistoryConsts.HISTORY_FILE) && file.getName().matches(".*\\.xml$")) { configFile = file; } } } 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(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(String folderName) { return returnEmptyFileArrayForNull( getJobDirectoryIncludingFolder(folderName).listFiles(DeletedFileFilter.INSTANCE)); } @Override public File[] getJobs(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(String folderName) { final String realFolderName = folderName.isEmpty() ? folderName : folderName + "/jobs"; return new File(getJobHistoryRootDir(), realFolderName); } /** * Returns the history directory for a node in a folder. * * @param folderName name of the folder. * @return history directory for a node in a folder. */ private File getNodeDirectoryIncludingFolder(String folderName) { final String realFolderName = folderName.isEmpty() ? folderName : folderName + "/nodes"; return new File(getNodeHistoryRootDir(), 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(String name) { return getRevisions(new File(historyRootDir, name), new File(name)); } @Override public void copyHistoryAndDelete(String oldName, 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(Node node) { final String content = Jenkins.XSTREAM2.toXML(node); createNewHistoryEntryAndSaveConfig(node, content, Messages.ConfigHistoryListenerHelper_CREATED()); } /** * Creates a new history entry and saves the slave configuration. * * @param node node. * @param content content. * @param operation operation. */ void createNewHistoryEntryAndSaveConfig(Node node, String content, final String operation) { final File timestampedDir = createNewHistoryEntry(node, operation); final File nodeConfigHistoryFile = new File(timestampedDir, "config.xml"); PrintStream stream = null; try { stream = new PrintStream(nodeConfigHistoryFile); stream.print(content); } catch (IOException ex) { throw new RuntimeException("Unable to write " + nodeConfigHistoryFile, ex); } finally { if (stream != null) { stream.close(); } } } @Override public void deleteNode(Node node) { createNewHistoryEntry(node, Messages.ConfigHistoryListenerHelper_DELETED()); // 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() + JobConfigHistoryConsts.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(Node node, String oldName, 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 Hudson 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); createNewHistoryEntryAndSaveConfig(node, content, Messages.ConfigHistoryListenerHelper_RENAMED()); } @Override public SortedMap<String, HistoryDescr> getRevisions(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; } } File getRootDir(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); } File createNewHistoryEntry(Node node, final String operation) { 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); 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 Hudson 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 Hudson configuration file. */ File getHistoryDirForNode(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); } boolean hasDuplicateHistory(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 */ boolean checkDuplicate(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 copyNodeHistoryAndDelete(String oldName, String newName) { final File oldFile = new File(getNodeHistoryRootDir(), oldName); final File newFile = new File(getNodeHistoryRootDir(), 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 saveNode(Node node) { final String content = Jenkins.XSTREAM2.toXML(node); if (checkDuplicate(node)) { createNewHistoryEntryAndSaveConfig(node, content, Messages.ConfigHistoryListenerHelper_CHANGED()); } } @Override public XmlFile getOldRevision(Node node, String identifier) { final File historyDir = new File(getHistoryDirForNode(node), identifier); return new XmlFile(getConfigFile(historyDir)); } @Override public boolean hasOldRevision(Node node, String identifier) { final XmlFile oldRevision = getOldRevision(node, identifier); return oldRevision.getFile() != null && oldRevision.getFile().exists(); } @Override public File[] getDeletedNodes(String folderName) { return returnEmptyFileArrayForNull( getNodeDirectoryIncludingFolder(folderName).listFiles(DeletedFileFilter.INSTANCE)); } @Override public File[] getNodes(String folderName) { return returnEmptyFileArrayForNull( getNodeDirectoryIncludingFolder(folderName).listFiles(NonDeletedFileFilter.INSTANCE)); } @Override public SortedMap<String, HistoryDescr> getNodeHistory(String nodeName) { return getRevisions(new File(getNodeHistoryRootDir(), nodeName), new File(nodeName)); } }