package hudson.plugins.jobConfigHistory;
import hudson.Plugin;
import hudson.XmlFile;
import hudson.model.AbstractProject;
import hudson.model.Hudson;
import hudson.model.Saveable;
import hudson.model.Descriptor.FormException;
import hudson.util.FormValidation;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import javax.servlet.ServletException;
import net.sf.json.JSONObject;
import org.apache.commons.lang.StringUtils;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerRequest;
/**
* Class supporting global configuration settings, along with methods
* associated with the plugin itself.
*
* @author jborghi
*
*/
public class JobConfigHistory extends Plugin {
/** Root directory for storing configuration history. */
private String historyRootDir;
/** Maximum number of configuration history entries to keep. */
private String maxHistoryEntries;
/** Flag to indicate we should save 'system' level configurations
* A 'system' level configuration is defined as one stored directly
* under the HUDSON_ROOT directory.
*/
private boolean saveSystemConfiguration;
/** Flag to indicate if we should save history when it
* is a duplication of the previous saved configuration.
*/
private boolean skipDuplicateHistory;
/** Regular expression pattern for 'system' configuration
* files to exclude from saving.
*/
private String excludePattern;
/** Compiled regular expression pattern. */
private transient Pattern excludeRegexpPattern;
/** our logger. */
private static final Logger LOG = Logger.getLogger(JobConfigHistory.class.getName());
/**
* A filter to return only those directories of a file listing
* that represent configuration history directories.
*/
public static final FileFilter HISTORY_FILTER = new FileFilter() {
public boolean accept(File file) {
return isHistoryDir(file);
}
};
@Override
public void start() throws Exception {
load();
loadRegexpPatterns();
}
@Override
public void configure(StaplerRequest req, JSONObject formData)
throws IOException, ServletException, FormException {
historyRootDir = formData.getString("historyRootDir").trim();
maxHistoryEntries = formData.getString("maxHistoryEntries").trim();
saveSystemConfiguration = formData.getBoolean("saveSystemConfiguration");
skipDuplicateHistory = formData.getBoolean("skipDuplicateHistory");
excludePattern = formData.getString("excludePattern");
save();
loadRegexpPatterns();
}
/**
* @return The configured history root directory.
*/
public String getHistoryRootDir() {
return historyRootDir;
}
/**
* @return The maximum number of history entries to keep.
*/
public String getMaxHistoryEntries() {
return maxHistoryEntries;
}
/**
* This method is for convenience in testing.
* @param maxHistoryEntries
* The maximum number of history entries to keep
*/
protected void setMaxHistoryEntries(final String maxHistoryEntries) {
this.maxHistoryEntries = maxHistoryEntries;
}
/**
* @return true if we should save 'system' configurations.
*/
public boolean getSaveSystemConfiguration() {
return saveSystemConfiguration;
}
/**
* @return true if we should skip saving history that duplicates the prior saved configuration.
*/
public boolean getSkipDuplicateHistory() {
return skipDuplicateHistory;
}
/**
* @return The regular expression for 'system' file names to exclude from saving.
*/
public String getExcludePattern() {
return excludePattern;
}
/**
* Used by the configuration page.
* @return The default regular expression for 'system' file names to exclude from saving.
*/
public String getDefaultExcludePattern() {
return JobConfigHistoryConsts.DEFAULT_EXCLUDE;
}
/**
* Used for testing to verify invalid pattern not loaded.
* @return The loaded regexp pattern, or null if pattern was invalid.
*/
protected Pattern getExcludeRegexpPattern() {
return excludeRegexpPattern;
}
/**
* Loads regular expression patterns used by this class.
*/
private void loadRegexpPatterns() {
excludeRegexpPattern = loadRegex(excludePattern);
}
/**
* Loads a regular expression pattern for the given string.
* @param patternString
* The string representing the regular expression.
* @return The {@link Pattern} for the given expression, or null if
* the pattern cannot be loaded.
*/
private Pattern loadRegex(final String patternString) {
if (patternString != null) {
try {
return Pattern.compile(patternString);
} catch (PatternSyntaxException e) {
return null;
}
}
return null;
}
/**
* Returns the File object representing the configured root history directory.
*
* @return The configured root history File object, or null if this configuration has not been set.
*/
protected File getConfiguredHistoryRootDir() {
File rootFile = null;
if (StringUtils.isNotBlank(historyRootDir)) {
if (historyRootDir.matches("^(/|\\\\|[a-zA-Z]:).*")) {
rootFile = new File(historyRootDir);
} else {
rootFile = new File(Hudson.getInstance().root.getPath() + "/" + historyRootDir);
}
}
return rootFile;
}
/**
* Returns the configuration history directory for the given configuration file.
*
* @param xmlFile
* 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.
*/
protected File getHistoryDir(final XmlFile xmlFile) {
final String configRootDir = xmlFile.getFile().getParent();
final String hudsonRootDir = Hudson.getInstance().root.getPath();
if (!configRootDir.startsWith(hudsonRootDir)) {
LOG.warning("Trying to get history dir for object outside of HUDSON: " + xmlFile);
return null;
}
// if the file is stored directly under HUDSON_ROOT, create a distinct directory
String underRootDir = null;
if (configRootDir.equals(hudsonRootDir)) {
final String xmlFileName = xmlFile.getFile().getName();
underRootDir = JobConfigHistoryConsts.DEFAULT_HISTORY_DIR + "/"
+ xmlFileName.substring(0, xmlFileName.lastIndexOf('.'));
}
File historyDir;
final File actualHistoryRoot = getConfiguredHistoryRootDir();
if (actualHistoryRoot == null) {
if (underRootDir == null) {
historyDir = new File(configRootDir, JobConfigHistoryConsts.DEFAULT_HISTORY_DIR);
} else {
historyDir = new File(configRootDir, underRootDir);
}
} else {
if (underRootDir == null) {
final String remainingPath = configRootDir.substring(hudsonRootDir.length() + 1);
historyDir = new File(actualHistoryRoot, remainingPath);
} else {
historyDir = new File(actualHistoryRoot, underRootDir);
}
}
return historyDir;
}
/**
* Returns the directory for storing system configurations.
*
* @return The directory used for storing system configurations.
*/
protected File getSystemHistoryDir() {
final File actualHistoryRoot = getConfiguredHistoryRootDir();
if (actualHistoryRoot != null) {
return new File(actualHistoryRoot, JobConfigHistoryConsts.DEFAULT_HISTORY_DIR);
} else {
return new File(Hudson.getInstance().root, JobConfigHistoryConsts.DEFAULT_HISTORY_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.
*/
protected File getConfigFile(final File historyDir) {
File configFile = null;
if (historyDir.exists() && isHistoryDir(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;
break;
}
}
}
return configFile;
}
/**
* Returns true if configuration for this item should be saved, based on the
* plugin settings, the type of item and the configuration file specified.
*
* <p>
* If the item is an instance of {@link AbstractProject} or the configuration
* file is stored directly in HUDSON_ROOT, it is considered for saving.
*
* If the plugin is configured to skip saving duplicated history, we also evaluate
* if this configuration duplicates the previous saved history (if such history exists).
*
* @param item
* The item whose configuration is under consideration.
* @param xmlFile
* The configuration file for the above item.
* @return true if the item configuration should be saved.
*/
protected boolean isSaveable(final Saveable item, final XmlFile xmlFile) {
boolean saveable = false;
if (item instanceof AbstractProject<?, ?>) {
saveable = true;
} else if (saveSystemConfiguration && xmlFile.getFile().getParentFile().equals(Hudson.getInstance().root)) {
if (excludeRegexpPattern != null) {
final Matcher matcher = excludeRegexpPattern.matcher(xmlFile.getFile().getName());
saveable = !matcher.find();
} else {
saveable = true;
}
}
if (saveable && skipDuplicateHistory && hasDuplicateHistory(xmlFile)) {
LOG.fine("found duplicate history, skipping save of " + xmlFile);
saveable = false;
}
return saveable;
}
/**
* 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.
*/
private boolean hasDuplicateHistory(XmlFile xmlFile) {
boolean isDuplicated = false;
final File[] historyDirs = getHistoryDir(xmlFile).listFiles(HISTORY_FILTER);
if (historyDirs != null && historyDirs.length != 0) {
Arrays.sort(historyDirs, Collections.reverseOrder());
final File lastFile = new File(historyDirs[0], xmlFile.getFile().getName());
if (lastFile.exists()) {
final XmlFile lastXmlFile = new XmlFile(lastFile);
try {
if (xmlFile.asString().equals(lastXmlFile.asString())) {
isDuplicated = true;
}
} catch (IOException e) {
LOG.warning("unable to check for duplicate previous history file: " + lastXmlFile + "\n" + e);
}
}
}
return isDuplicated;
}
/**
* Checks if we should purge old history entries under the specified root
* using the {@code maxHistoryEntries} value as the criteria, and if required
* calls the appropriate method to perform the purge.
*
* @param itemHistoryRoot
* The directory to consider purging history under.
*/
protected void checkForPurgeByQuantity(final File itemHistoryRoot) {
int maxEntries = 0;
if (StringUtils.isNotEmpty(maxHistoryEntries)) {
try {
maxEntries = new Integer(getMaxHistoryEntries());
maxEntries = Integer.parseInt(getMaxHistoryEntries());
if (maxEntries < 0) {
throw new NumberFormatException();
}
} catch (NumberFormatException e) {
LOG.warning("maximum number of history entries not formatted properly, unable to purge: " + maxHistoryEntries);
}
}
if (maxEntries > 0) {
LOG.fine("checking for history files to purge (" + maxHistoryEntries + " max allowed)");
purgeHistoryByQuantity(itemHistoryRoot, maxEntries);
}
}
/**
* Performs the actual purge of history entries.
* @param historyRoot
* The directory to purge entries from.
* @param maxEntries
* The maximum number of history entries to keep.
*/
private void purgeHistoryByQuantity(final File historyRoot, final int maxEntries) {
// we are about to create a new history entry, so
// subtract 1 from the maximum configured to save.
final int entriesToLeave = maxEntries - 1;
final File[] historyDirs = historyRoot.listFiles(HISTORY_FILTER);
if (historyDirs != null && historyDirs.length >= entriesToLeave) {
Arrays.sort(historyDirs, Collections.reverseOrder());
for (int i = entriesToLeave; i < historyDirs.length; i++) {
LOG.fine("purging old directory from history logs: " + historyDirs[i]);
for (File file : historyDirs[i].listFiles()) {
if (!file.delete()) {
LOG.warning("problem deleting history file: " + file);
}
}
if (!historyDirs[i].delete()) {
LOG.warning("problem deleting history directory: " + historyDirs[i]);
}
}
}
}
/**
* Determines if the specified {@code dir} stores
* history information. This is needed as Hudson creates
* 'modules' directories under the 'job' folder, and these will
* be mixed in with the timestamped history configurations.
*
* @param dir
* The directory under consideration.
* @return true if this directory contains a {@link JobConfigHistoryConsts#HISTORY_FILE} file.
*/
private static boolean isHistoryDir(File dir) {
return (new File(dir, JobConfigHistoryConsts.HISTORY_FILE)).exists();
}
/**
* Validates the user entry for the maximum number of history items to keep.
* Must be blank or a non-negative integer.
* @param value
* The form input entered by the user.
* @return ok if the entry is blank or a non-negative integer.
*/
public FormValidation doCheckMaxHistoryEntries(@QueryParameter final String value) {
try {
if (StringUtils.isNotBlank(value) && Integer.parseInt(value) < 0) {
throw new NumberFormatException();
}
return FormValidation.ok();
} catch (NumberFormatException ex) {
return FormValidation.error("Enter a valid positive integer");
}
}
/**
* Validates the user entry for the regular expression of system file names
* to exclude from saving.
* @param value
* The form input entered by the user.
* @return ok if the entry is a valid regular expression.
*/
public FormValidation doCheckExcludePattern(@QueryParameter final String value) {
try {
Pattern.compile(value);
return FormValidation.ok();
} catch (PatternSyntaxException e) {
return FormValidation.error("Invalid regexp:\n" + e);
}
}
}