azkaban-aplcache

Details

.gitignore 1(+1 -0)

diff --git a/.gitignore b/.gitignore
index a55a10d..356c17c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+bin/
 build/
 .gradle/
 .settings/
diff --git a/azkaban-common/src/main/java/azkaban/project/ProjectManager.java b/azkaban-common/src/main/java/azkaban/project/ProjectManager.java
index 54ab4dd..64c450d 100644
--- a/azkaban-common/src/main/java/azkaban/project/ProjectManager.java
+++ b/azkaban-common/src/main/java/azkaban/project/ProjectManager.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -32,6 +33,10 @@ import org.apache.log4j.Logger;
 
 import azkaban.flow.Flow;
 import azkaban.project.ProjectLogEvent.EventType;
+import azkaban.project.validator.ValidationStatus;
+import azkaban.project.validator.ValidationReport;
+import azkaban.project.validator.ValidatorManager;
+import azkaban.project.validator.XmlValidatorManager;
 import azkaban.user.Permission;
 import azkaban.user.User;
 import azkaban.user.Permission.Type;
@@ -42,11 +47,14 @@ import azkaban.utils.Utils;
 public class ProjectManager {
   private static final Logger logger = Logger.getLogger(ProjectManager.class);
 
+  public static final String PROJECT_ARCHIVE_FILE_PATH = "project.archive.file.path";
+
   private ConcurrentHashMap<Integer, Project> projectsById =
       new ConcurrentHashMap<Integer, Project>();
   private ConcurrentHashMap<String, Project> projectsByName =
       new ConcurrentHashMap<String, Project>();
   private final ProjectLoader projectLoader;
+  private final ValidatorManager validatorManager;
   private final Props props;
   private final File tempDir;
   private final int projectVersionRetention;
@@ -68,6 +76,7 @@ public class ProjectManager {
       tempDir.mkdirs();
     }
 
+    validatorManager = new XmlValidatorManager(props);
     loadAllProjects();
   }
 
@@ -337,7 +346,7 @@ public class ProjectManager {
     }
   }
 
-  public void uploadProject(Project project, File archive, String fileType,
+  public Map<String, ValidationReport> uploadProject(Project project, File archive, String fileType,
       User uploader) throws ProjectManagerException {
     logger.info("Uploading files to " + project.getName());
 
@@ -357,10 +366,18 @@ public class ProjectManager {
       throw new ProjectManagerException("Error unzipping file.", e);
     }
 
-    logger.info("Validating Flow for upload " + archive.getName());
-    DirectoryFlowLoader loader = new DirectoryFlowLoader(logger);
-    loader.loadProjectFlow(file);
-    if (!loader.getErrors().isEmpty()) {
+    props.put(PROJECT_ARCHIVE_FILE_PATH, archive.getAbsolutePath());
+    validatorManager.loadValidators(props, logger);
+    logger.info("Validating project " + archive.getName() + " using the registered validators "
+        + validatorManager.getValidatorsInfo().toString());
+    Map<String, ValidationReport> reports = validatorManager.validate(file);
+    ValidationStatus status = ValidationStatus.PASS;
+    for (Entry<String, ValidationReport> report : reports.entrySet()) {
+      if (report.getValue().getStatus().compareTo(status) > 0) {
+        status = report.getValue().getStatus();
+      }
+    }
+    if (status == ValidationStatus.ERROR) {
       logger.error("Error found in upload to " + project.getName()
           + ". Cleaning up.");
 
@@ -371,16 +388,10 @@ public class ProjectManager {
         e.printStackTrace();
       }
 
-      StringBuffer errorMessage = new StringBuffer();
-      errorMessage.append("Error found in upload. Cannot upload.\n");
-      for (String error : loader.getErrors()) {
-        errorMessage.append(error);
-        errorMessage.append('\n');
-      }
-
-      throw new ProjectManagerException(errorMessage.toString());
+      return reports;
     }
 
+    DirectoryFlowLoader loader = (DirectoryFlowLoader) validatorManager.getDefaultValidator();
     Map<String, Props> jobProps = loader.getJobProps();
     List<Props> propProps = loader.getProps();
 
@@ -422,6 +433,8 @@ public class ProjectManager {
         + (project.getVersion() - projectVersionRetention));
     projectLoader.cleanOlderProjectVersion(project.getId(),
         project.getVersion() - projectVersionRetention);
+
+    return reports;
   }
 
   public void updateFlow(Project project, Flow flow)
diff --git a/azkaban-common/src/main/java/azkaban/project/validator/ProjectValidator.java b/azkaban-common/src/main/java/azkaban/project/validator/ProjectValidator.java
new file mode 100644
index 0000000..b1b10b8
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/project/validator/ProjectValidator.java
@@ -0,0 +1,37 @@
+package azkaban.project.validator;
+
+import java.io.File;
+
+import azkaban.utils.Props;
+
+/**
+ * Interface to be implemented by plugins which are to be registered with Azkaban
+ * as project validators that validate a project before uploaded into Azkaban.
+ */
+public interface ProjectValidator {
+
+  /**
+   * Initialize the validator using the given properties.
+   *
+   * @param configuration
+   * @return
+   */
+  boolean initialize(Props configuration);
+
+  /**
+   * Return a user friendly name of the validator.
+   *
+   * @return
+   */
+  String getValidatorName();
+
+  /**
+   * Validate the project inside the given directory. The validator, using its own
+   * validation logic, will generate a {@link ValidationReport} representing the result of
+   * the validation.
+   *
+   * @param projectDir
+   * @return
+   */
+  ValidationReport validateProject(File projectDir);
+}
diff --git a/azkaban-common/src/main/java/azkaban/project/validator/ValidationReport.java b/azkaban-common/src/main/java/azkaban/project/validator/ValidationReport.java
new file mode 100644
index 0000000..9cccffb
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/project/validator/ValidationReport.java
@@ -0,0 +1,103 @@
+package azkaban.project.validator;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * The result of a project validation generated by a {@link ProjectValidator}. It contains
+ * an enum of type {@link ValidationStatus} representing whether the validation passes,
+ * generates warnings, or generates errors. Accordingly, three sets of String are also
+ * maintained, storing the messages generated by the {@link ProjectValidator} at each of
+ * the 3 {@link ValidationStatus} levels, i.e., {@link ValidationStatus#PASS},
+ * {@link ValidationStatus#WARN}, and {@link ValidationStatus#ERROR}.
+ */
+public class ValidationReport {
+
+  protected ValidationStatus _status;
+  protected Set<String> _passMsgs;
+  protected Set<String> _warningMsgs;
+  protected Set<String> _errorMsgs;
+
+  public ValidationReport() {
+    _status = ValidationStatus.PASS;
+    _passMsgs = new HashSet<String>();
+    _warningMsgs = new HashSet<String>();
+    _errorMsgs = new HashSet<String>();
+  }
+
+  /**
+   * Add a message with status level being {@link ValidationStatus#PASS}
+   *
+   * @param msgs
+   */
+  public void addPassMsgs(Set<String> msgs) {
+    if (msgs != null) {
+      _passMsgs.addAll(msgs);
+    }
+  }
+
+  /**
+   * Add a message with status level being {@link ValidationStatus#WARN}
+   *
+   * @param msgs
+   */
+  public void addWarningMsgs(Set<String> msgs) {
+    if (msgs != null) {
+      _warningMsgs.addAll(msgs);
+      if (!msgs.isEmpty() && _errorMsgs.isEmpty()) {
+        _status = ValidationStatus.WARN;
+      }
+    }
+  }
+
+  /**
+   * Add a message with status level being {@link ValidationStatus#ERROR}
+   *
+   * @param msgs
+   */
+  public void addErrorMsgs(Set<String> msgs) {
+    if (msgs != null) {
+      _errorMsgs.addAll(msgs);
+      if (!msgs.isEmpty()) {
+        _status = ValidationStatus.ERROR;
+      }
+    }
+  }
+
+  /**
+   * Retrieve the status of the report.
+   *
+   * @return
+   */
+  public ValidationStatus getStatus() {
+    return _status;
+  }
+
+  /**
+   * Retrieve the messages associated with status level {@link ValidationStatus#PASS}
+   *
+   * @return
+   */
+  public Set<String> getPassMsgs() {
+    return _passMsgs;
+  }
+
+  /**
+   * Retrieve the messages associated with status level {@link ValidationStatus#WARN}
+   *
+   * @return
+   */
+  public Set<String> getWarningMsgs() {
+    return _warningMsgs;
+  }
+
+  /**
+   * Retrieve the messages associated with status level {@link ValidationStatus#ERROR}
+   *
+   * @return
+   */
+  public Set<String> getErrorMsgs() {
+    return _errorMsgs;
+  }
+
+}
diff --git a/azkaban-common/src/main/java/azkaban/project/validator/ValidationStatus.java b/azkaban-common/src/main/java/azkaban/project/validator/ValidationStatus.java
new file mode 100644
index 0000000..4b1f0f4
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/project/validator/ValidationStatus.java
@@ -0,0 +1,22 @@
+package azkaban.project.validator;
+
+/**
+ * Status of the ValidationReport. It also represents the severity of each rule.
+ * The order of severity for the status is PASS < WARN < ERROR.
+ */
+public enum ValidationStatus {
+  PASS("PASS"),
+  WARN("WARN"),
+  ERROR("ERROR");
+
+  private final String _status;
+
+  private ValidationStatus(final String status) {
+    _status = status;
+  }
+
+  @Override
+  public String toString() {
+    return _status;
+  }
+}
diff --git a/azkaban-common/src/main/java/azkaban/project/validator/ValidatorManager.java b/azkaban-common/src/main/java/azkaban/project/validator/ValidatorManager.java
new file mode 100644
index 0000000..1be62fb
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/project/validator/ValidatorManager.java
@@ -0,0 +1,51 @@
+package azkaban.project.validator;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.log4j.Logger;
+
+import azkaban.utils.Props;
+
+/**
+ * ValidatorManager is responsible for loading the list of validators specified in the
+ * Azkaban validator configuration file. Once these validators are loaded, the ValidatorManager
+ * will use the registered validators to verify each uploaded project before persisting it.
+ */
+public interface ValidatorManager {
+  /**
+   * Load the validators using the given properties. Each validator is also given the specified
+   * logger to record any necessary message in the Azkaban log file.
+   *
+   * @param props
+   * @param logger
+   */
+  void loadValidators(Props props, Logger logger);
+
+  /**
+   * Validate the given project using the registered list of validators. This method returns a
+   * map of {@link ValidationReport} with the key being the validator's name and the value being
+   * the {@link ValidationReport} generated by that validator.
+   *
+   * @param projectDir
+   * @return
+   */
+  Map<String, ValidationReport> validate(File projectDir);
+
+  /**
+   * The ValidatorManager should have a default validator which checks for the most essential
+   * components of a project. The ValidatorManager should always load the default validator.
+   * This method returns the default validator of this ValidatorManager.
+   *
+   * @return
+   */
+  ProjectValidator getDefaultValidator();
+
+  /**
+   * Returns a list of String containing the name of each registered validators.
+   *
+   * @return
+   */
+  List<String> getValidatorsInfo();
+}
diff --git a/azkaban-common/src/main/java/azkaban/project/validator/ValidatorManagerException.java b/azkaban-common/src/main/java/azkaban/project/validator/ValidatorManagerException.java
new file mode 100644
index 0000000..8b04c2f
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/project/validator/ValidatorManagerException.java
@@ -0,0 +1,18 @@
+package azkaban.project.validator;
+
+public class ValidatorManagerException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  public ValidatorManagerException(String message) {
+    super(message);
+  }
+
+  public ValidatorManagerException(Throwable cause) {
+    super(cause);
+  }
+
+  public ValidatorManagerException(String message, Throwable cause) {
+    super(message, cause);
+  }
+
+}
diff --git a/azkaban-common/src/main/java/azkaban/project/validator/XmlValidatorManager.java b/azkaban-common/src/main/java/azkaban/project/validator/XmlValidatorManager.java
new file mode 100644
index 0000000..dc05c1a
--- /dev/null
+++ b/azkaban-common/src/main/java/azkaban/project/validator/XmlValidatorManager.java
@@ -0,0 +1,226 @@
+package azkaban.project.validator;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.parsers.ParserConfigurationException;
+
+import org.apache.log4j.Logger;
+import org.w3c.dom.Document;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.SAXException;
+
+import azkaban.utils.DirectoryFlowLoader;
+import azkaban.utils.Props;
+
+/**
+ * Xml implementation of the ValidatorManager. Looks for the property
+ * project.validators.xml.file in the azkaban properties.
+ *
+ * The xml to be in the following form:
+ * <azkaban-validators>
+ *   <validator classname="validator class name">
+ *     <!-- optional configurations for each individual validator -->
+ *     <property key="validator property key" value="validator property value" />
+ *     ...
+ *   </validator>
+ * </azkaban-validators>
+ */
+public class XmlValidatorManager implements ValidatorManager {
+  private static final Logger logger = Logger.getLogger(XmlValidatorManager.class);
+
+  public static final String DEFAULT_VALIDATOR_DIR = "validators";
+  public static final String VALIDATOR_PLUGIN_DIR = "project.validators.dir";
+  public static final String XML_FILE_PARAM = "project.validators.xml.file";
+  public static final String AZKABAN_VALIDATOR_TAG = "azkaban-validators";
+  public static final String VALIDATOR_TAG = "validator";
+  public static final String CLASSNAME_ATTR = "classname";
+  public static final String ITEM_TAG = "property";
+  public static final String DEFAULT_VALIDATOR_KEY = "Directory Flow";
+
+  private Map<String, ProjectValidator> validators;
+  private ClassLoader validatorLoader;
+
+  /**
+   * Load the validator plugins from the validator directory (default being validators/) into
+   * the validator ClassLoader. This enables creating instances of these validators in the
+   * loadValidators() method.
+   *
+   * @param props
+   */
+  public XmlValidatorManager(Props props) {
+    String validatorDirPath = props.getString(VALIDATOR_PLUGIN_DIR, DEFAULT_VALIDATOR_DIR);
+    File validatorDir = new File(validatorDirPath);
+    if (!validatorDir.canRead() || !validatorDir.isDirectory()) {
+      throw new ValidatorManagerException("Validator directory " + validatorDirPath
+          + " does not exist or is not a directory.");
+    }
+
+    List<URL> resources = new ArrayList<URL>();
+    try {
+      logger.info("Adding validator resources.");
+      for (File f : validatorDir.listFiles()) {
+        if (f.getName().endsWith(".jar")) {
+          resources.add(f.toURI().toURL());
+          logger.debug("adding to classpath " + f.toURI().toURL());
+        }
+      }
+    } catch (MalformedURLException e) {
+      throw new ValidatorManagerException(e);
+    }
+    validatorLoader = new URLClassLoader(resources.toArray(new URL[resources.size()]));
+
+    // Test loading the validators specified in the xml file.
+    try {
+      loadValidators(props, logger);
+    } catch (Exception e) {
+      logger.error("Cannot load all the validators.");
+      throw new ValidatorManagerException(e);
+    }
+  }
+
+  /**
+   * Instances of the validators are created here rather than in the constructors. This is because
+   * some validators might need to maintain project-specific states, such as {@link DirectoryFlowLoader}.
+   * By instantiating the validators here, it ensures that the validator objects are project-specific,
+   * rather than global.
+   *
+   * {@inheritDoc}
+   * @see azkaban.project.validator.ValidatorManager#loadValidators(azkaban.utils.Props, org.apache.log4j.Logger)
+   */
+  @Override
+  public void loadValidators(Props props, Logger log) {
+    validators = new LinkedHashMap<String, ProjectValidator>();
+    // Add the default validator
+    DirectoryFlowLoader flowLoader = new DirectoryFlowLoader(log);
+    validators.put(flowLoader.getValidatorName(), flowLoader);
+
+    if (!props.containsKey(XML_FILE_PARAM)) {
+      logger.warn("Azkaban properties file does not contain the key " + XML_FILE_PARAM);
+      return;
+    }
+    String xmlPath = props.get(XML_FILE_PARAM);
+    File file = new File(xmlPath);
+    if (!file.exists()) {
+      logger.error("Azkaban validator configuration file " + xmlPath + " does not exist.");
+      return;
+    }
+
+    // Creating the document builder to parse xml.
+    DocumentBuilderFactory docBuilderFactory =
+        DocumentBuilderFactory.newInstance();
+    DocumentBuilder builder = null;
+    try {
+      builder = docBuilderFactory.newDocumentBuilder();
+    } catch (ParserConfigurationException e) {
+      throw new ValidatorManagerException(
+          "Exception while parsing validator xml. Document builder not created.", e);
+    }
+
+    Document doc = null;
+    try {
+      doc = builder.parse(file);
+    } catch (SAXException e) {
+      throw new ValidatorManagerException("Exception while parsing " + xmlPath
+          + ". Invalid XML.", e);
+    } catch (IOException e) {
+      throw new ValidatorManagerException("Exception while parsing " + xmlPath
+          + ". Error reading file.", e);
+    }
+
+    NodeList tagList = doc.getChildNodes();
+    Node azkabanValidators = tagList.item(0);
+
+    NodeList azkabanValidatorsList = azkabanValidators.getChildNodes();
+    for (int i = 0; i < azkabanValidatorsList.getLength(); ++i) {
+      Node node = azkabanValidatorsList.item(i);
+      if (node.getNodeType() == Node.ELEMENT_NODE) {
+        if (node.getNodeName().equals(VALIDATOR_TAG)) {
+          parseValidatorTag(node, props, log);
+        }
+      }
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private void parseValidatorTag(Node node, Props props, Logger log) {
+    NamedNodeMap validatorAttrMap = node.getAttributes();
+    Node classNameAttr = validatorAttrMap.getNamedItem(CLASSNAME_ATTR);
+    if (classNameAttr == null) {
+      throw new ValidatorManagerException(
+          "Error loading validator. The validator 'classname' attribute doesn't exist");
+    }
+
+    NodeList keyValueItemsList = node.getChildNodes();
+    for (int i = 0; i < keyValueItemsList.getLength(); i++) {
+      Node keyValuePair = keyValueItemsList.item(i);
+      if (keyValuePair.getNodeName().equals(ITEM_TAG)) {
+        parseItemTag(keyValuePair, props);
+      }
+    }
+    String className = classNameAttr.getNodeValue();
+    try {
+      Class<? extends ProjectValidator> validatorClass =
+          (Class<? extends ProjectValidator>)validatorLoader.loadClass(className);
+      Constructor<?> validatorConstructor =
+          validatorClass.getConstructor(Logger.class);
+      ProjectValidator validator = (ProjectValidator) validatorConstructor.newInstance(log);
+      validator.initialize(props);
+      validators.put(validator.getValidatorName(), validator);
+      logger.info("Added validator " + className + " to list of validators.");
+    } catch (Exception e) {
+      logger.error("Could not instantiate ProjectValidator " + className);
+      throw new ValidatorManagerException(e);
+    }
+  }
+
+  private void parseItemTag(Node node, Props props) {
+    NamedNodeMap keyValueMap = node.getAttributes();
+    Node keyAttr = keyValueMap.getNamedItem("key");
+    Node valueAttr = keyValueMap.getNamedItem("value");
+    if (keyAttr == null || valueAttr == null) {
+      throw new ValidatorManagerException("Error loading validator key/value "
+          + "pair. The 'key' or 'value' attribute doesn't exist");
+    }
+    props.put(keyAttr.getNodeValue(), valueAttr.getNodeValue());
+  }
+
+  @Override
+  public Map<String, ValidationReport> validate(File projectDir) {
+    Map<String, ValidationReport> reports = new LinkedHashMap<String, ValidationReport>();
+    for (Entry<String, ProjectValidator> validator : validators.entrySet()) {
+      reports.put(validator.getKey(), validator.getValue().validateProject(projectDir));
+      logger.info("Validation status of validator " + validator.getKey() + " is "
+          + reports.get(validator.getKey()).getStatus());
+    }
+    return reports;
+  }
+
+  @Override
+  public ProjectValidator getDefaultValidator() {
+    return validators.get(DEFAULT_VALIDATOR_KEY);
+  }
+
+  @Override
+  public List<String> getValidatorsInfo() {
+    List<String> info = new ArrayList<String>();
+    for (String key : validators.keySet()) {
+      info.add(key);
+    }
+    return info;
+  }
+
+}
diff --git a/azkaban-common/src/main/java/azkaban/utils/DirectoryFlowLoader.java b/azkaban-common/src/main/java/azkaban/utils/DirectoryFlowLoader.java
index 0353d5f..0ccd1dc 100644
--- a/azkaban-common/src/main/java/azkaban/utils/DirectoryFlowLoader.java
+++ b/azkaban-common/src/main/java/azkaban/utils/DirectoryFlowLoader.java
@@ -36,8 +36,11 @@ import azkaban.flow.Flow;
 import azkaban.flow.FlowProps;
 import azkaban.flow.Node;
 import azkaban.flow.SpecialJobTypes;
+import azkaban.project.validator.ProjectValidator;
+import azkaban.project.validator.ValidationReport;
+import azkaban.project.validator.XmlValidatorManager;
 
-public class DirectoryFlowLoader {
+public class DirectoryFlowLoader implements ProjectValidator {
   private static final DirFilter DIR_FILTER = new DirFilter();
   private static final String PROPERTY_SUFFIX = ".properties";
   private static final String JOB_SUFFIX = ".job";
@@ -396,4 +399,22 @@ public class DirectoryFlowLoader {
           && name.length() > suffix.length() && name.endsWith(suffix);
     }
   }
+
+  @Override
+  public boolean initialize(Props configuration) {
+    return true;
+  }
+
+  @Override
+  public String getValidatorName() {
+    return XmlValidatorManager.DEFAULT_VALIDATOR_KEY;
+  }
+
+  @Override
+  public ValidationReport validateProject(File projectDir) {
+    loadProjectFlow(projectDir);
+    ValidationReport report = new ValidationReport();
+    report.addErrorMsgs(errors);
+    return report;
+  }
 }
diff --git a/azkaban-common/src/test/java/azkaban/project/validator/XmlValidatorManagerTest.java b/azkaban-common/src/test/java/azkaban/project/validator/XmlValidatorManagerTest.java
new file mode 100644
index 0000000..1e97efa
--- /dev/null
+++ b/azkaban-common/src/test/java/azkaban/project/validator/XmlValidatorManagerTest.java
@@ -0,0 +1,81 @@
+package azkaban.project.validator;
+
+import static org.junit.Assert.*;
+
+import java.net.URL;
+
+import org.junit.Test;
+
+import com.google.common.io.Resources;
+
+import azkaban.utils.Props;
+
+public class XmlValidatorManagerTest {
+  private Props baseProps = new Props();
+
+  /**
+   * Test that if the validator directory does not exist, XmlValidatorManager
+   * should throw an exception.
+   */
+  @Test(expected=ValidatorManagerException.class)
+  public void testNoValidatorsDir() {
+    Props props = new Props(baseProps);
+
+    new XmlValidatorManager(props);
+  }
+
+  /**
+   * Test that if the validator directory exists but the xml configuration file does not,
+   * XmlValidatorManager only loads the default validator.
+   */
+  @Test
+  public void testDefaultValidator() {
+    Props props = new Props(baseProps);
+    URL validatorUrl = Resources.getResource("project/testValidators");
+    props.put(XmlValidatorManager.VALIDATOR_PLUGIN_DIR, validatorUrl.getPath());
+
+    XmlValidatorManager manager = new XmlValidatorManager(props);
+    assertEquals("XmlValidatorManager should contain only the default validator when no xml configuration "
+        + "file is present.", manager.getValidatorsInfo().size(), 1);
+    assertEquals("XmlValidatorManager should contain only the default validator when no xml configuration "
+        + "file is present.", manager.getValidatorsInfo().get(0), XmlValidatorManager.DEFAULT_VALIDATOR_KEY);
+  }
+
+  /**
+   * Test that if the xml config file specifies a validator classname that does not exist,
+   * XmlValidatorManager should throw an exception.
+   */
+  @Test(expected=ValidatorManagerException.class)
+  public void testValidatorDoesNotExist() {
+    Props props = new Props(baseProps);
+    URL validatorUrl = Resources.getResource("project/testValidators");
+    URL configUrl = Resources.getResource("test-conf/azkaban-validators-test1.xml");
+    props.put(XmlValidatorManager.VALIDATOR_PLUGIN_DIR, validatorUrl.getPath());
+    props.put(XmlValidatorManager.XML_FILE_PARAM,
+        configUrl.getPath());
+
+    new XmlValidatorManager(props);
+
+  }
+
+  /**
+   * Test that if the xml config file is properly set, XmlValidatorManager loads both the default
+   * validator and the one specified in the xml file. The TestValidator class specified in the xml
+   * configuration file is located with the jar file inside test resource directory project/testValidators.
+   */
+  @Test
+  public void testLoadValidators() {
+    Props props = new Props(baseProps);
+    URL validatorUrl = Resources.getResource("project/testValidators");
+    URL configUrl = Resources.getResource("test-conf/azkaban-validators-test2.xml");
+    props.put(XmlValidatorManager.VALIDATOR_PLUGIN_DIR, validatorUrl.getPath());
+    props.put(XmlValidatorManager.XML_FILE_PARAM,
+        configUrl.getPath());
+
+    XmlValidatorManager manager = new XmlValidatorManager(props);
+    assertEquals("XmlValidatorManager should contain 2 validators.", manager.getValidatorsInfo().size(), 2);
+    assertEquals("XmlValidatorManager should contain the validator specified in the xml configuration file.",
+        manager.getValidatorsInfo().get(1), "Test");
+  }
+
+}
diff --git a/azkaban-common/src/test/resources/project/testValidators/test.jar b/azkaban-common/src/test/resources/project/testValidators/test.jar
new file mode 100644
index 0000000..327492c
Binary files /dev/null and b/azkaban-common/src/test/resources/project/testValidators/test.jar differ
diff --git a/azkaban-common/src/test/resources/test-conf/azkaban-validators-test1.xml b/azkaban-common/src/test/resources/test-conf/azkaban-validators-test1.xml
new file mode 100644
index 0000000..2d0d772
--- /dev/null
+++ b/azkaban-common/src/test/resources/test-conf/azkaban-validators-test1.xml
@@ -0,0 +1,3 @@
+<azkaban-validators>
+  <validator classname="do.not.exist.validator" />
+</azkaban-validators>
\ No newline at end of file
diff --git a/azkaban-common/src/test/resources/test-conf/azkaban-validators-test2.xml b/azkaban-common/src/test/resources/test-conf/azkaban-validators-test2.xml
new file mode 100644
index 0000000..681deee
--- /dev/null
+++ b/azkaban-common/src/test/resources/test-conf/azkaban-validators-test2.xml
@@ -0,0 +1,5 @@
+<azkaban-validators>
+  <validator classname="azkaban.project.validator.TestValidator">
+    <property key="key" value="value" />
+  </validator>
+</azkaban-validators>
\ No newline at end of file
diff --git a/azkaban-soloserver/.gitignore b/azkaban-soloserver/.gitignore
new file mode 100644
index 0000000..5ab4d17
--- /dev/null
+++ b/azkaban-soloserver/.gitignore
@@ -0,0 +1,8 @@
+conf/
+data/
+executions/
+plugins/
+projects/
+temp/
+validators/
+*.log
diff --git a/azkaban-webserver/src/main/java/azkaban/webapp/servlet/ProjectManagerServlet.java b/azkaban-webserver/src/main/java/azkaban/webapp/servlet/ProjectManagerServlet.java
index b80b18c..f8215aa 100644
--- a/azkaban-webserver/src/main/java/azkaban/webapp/servlet/ProjectManagerServlet.java
+++ b/azkaban-webserver/src/main/java/azkaban/webapp/servlet/ProjectManagerServlet.java
@@ -31,6 +31,7 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Set;
 
 import javax.servlet.ServletConfig;
@@ -56,6 +57,7 @@ import azkaban.project.Project;
 import azkaban.project.ProjectLogEvent;
 import azkaban.project.ProjectManager;
 import azkaban.project.ProjectManagerException;
+import azkaban.project.validator.ValidationReport;
 import azkaban.scheduler.Schedule;
 import azkaban.scheduler.ScheduleManager;
 import azkaban.scheduler.ScheduleManagerException;
@@ -1450,7 +1452,27 @@ public class ProjectManagerServlet extends LoginAbstractAzkabanServlet {
         IOUtils.copy(item.getInputStream(), out);
         out.close();
 
-        projectManager.uploadProject(project, archiveFile, type, user);
+        Map<String, ValidationReport> reports = projectManager.uploadProject(
+            project, archiveFile, type, user);
+        StringBuffer message = new StringBuffer();
+        for (Entry<String, ValidationReport> reportEntry : reports.entrySet()) {
+          ValidationReport report = reportEntry.getValue();
+          if (!report.getErrorMsgs().isEmpty()) {
+            message.append("Validator " + reportEntry.getKey() + " reports errors:\n");
+            for (String msg : report.getErrorMsgs()) {
+              message.append(msg + "\n");
+            }
+          }
+          if (!report.getWarningMsgs().isEmpty()) {
+            message.append("Validator " + reportEntry.getKey() + " reports warnings:\n");
+            for (String msg : report.getWarningMsgs()) {
+              message.append(msg + "\n");
+            }
+          }
+        }
+        if (message.length() > 0) {
+          ret.put("error", message.toString());
+        }
       } catch (Exception e) {
         logger.info("Installation Failed.", e);
         String error = e.getMessage();