Kaynağa Gözat

AMBARI-6869. FE: Ambari installer wizard should use the /validations API to validate host-component layout

Srimanth Gunturi 10 yıl önce
ebeveyn
işleme
2489e77115

+ 70 - 0
ambari-web/app/assets/data/stacks/HDP-2.1/validations.json

@@ -0,0 +1,70 @@
+{ "resources": [
+  {
+    "items": [
+      {
+        "type": "configuration",
+        "level": "ERROR",
+        "message": "Value should be integer",
+        "config-type": "mapred-site",
+        "config-name": "mapreduce.map.memory.mb"
+      },
+      {
+        "type": "configuration",
+        "level": "WARN",
+        "message": "Maximum memory exceeds map memory size",
+        "config-type": "mapred-site",
+        "config-name": "mapreduce.map.java.opt"
+      },
+      {
+        "type": "configuration",
+        "level": "ERROR",
+        "message": "yarn.new-config should be defined for HDP-2.1 Yarn service",
+        "config-type": "yarn-site",
+        "config-name": "yarn.new-config"
+      },
+      {
+        "type": "configuration",
+        "level": "WARN",
+        "message": "yarn.old-config has been deprecated in HDP-2.1",
+        "config-type": "yarn-site",
+        "config-name": "yarn.old-config"
+      },
+      {
+        "type": "host-component",
+        "level": "ERROR",
+        "message": "NameNode and Secondary NameNode cannot be hosted on same machine",
+        "component-name": "NAMENODE",
+        "host": "c6401.ambari.apache.org"
+      },
+      {
+        "type": "host-component",
+        "level": "ERROR",
+        "message": "NameNode and Secondary NameNode cannot be hosted on same machine",
+        "component-name": "SNAMENODE",
+        "host": "c6401.ambari.apache.org"
+      },
+      {
+        "type": "host-component",
+        "level": "WARN",
+        "message": "DataNode should not be places on c6401.ambari.apache.org DataNode should not be places on c6401.ambari.apache.org DataNode should not be places on c6401.ambari.apache.org  DataNode should not be places on c6401.ambari.apache.orgDataNode should not be places on c6401.ambari.apache.org",
+        "component-name": "DATANODE",
+        "host": "c6402.ambari.apache.org"
+      },
+      {
+        "type": "host-component",
+        "level": "WARN",
+        "message": "Another small message",
+        "component-name": "DATANODE",
+        "host": "c6402.ambari.apache.org"
+      },
+      {
+        "type": "host-component",
+        "level": "WARN",
+        "message": "It's better to not place HIVE_CLIENT on c6403.ambari.apache.org",
+        "component-name": "HIVE_CLIENT",
+        "host": "c6403.ambari.apache.org"
+      }
+    ]
+  }
+]
+}

+ 1 - 0
ambari-web/app/assets/test/tests.js

@@ -113,6 +113,7 @@ var files = ['test/init_model_test',
   'test/utils/ajax/ajax_test',
   'test/utils/ajax/ajax_queue_test',
   'test/utils/batch_scheduled_requests_test',
+  'test/utils/blueprint_test',
   'test/utils/config_test',
   'test/utils/date_test',
   'test/utils/config_test',

+ 210 - 18
ambari-web/app/controllers/wizard/step5_controller.js

@@ -18,6 +18,7 @@
 
 var App = require('app');
 var numberUtils = require('utils/number_utils');
+var validationUtils = require('utils/validator');
 
 App.WizardStep5Controller = Em.Controller.extend({
 
@@ -129,6 +130,16 @@ App.WizardStep5Controller = Em.Controller.extend({
    */
   isLoaded: false,
 
+  /**
+   * Validation error messages which don't related with any master
+   */
+  generalErrorMessages: [],
+
+  /**
+   * Validation warning messages which don't related with any master
+   */
+  generalWarningMessages: [],
+
   /**
    * List of host with assigned masters
    * Format:
@@ -185,15 +196,154 @@ App.WizardStep5Controller = Em.Controller.extend({
 
   /**
    * Update submit button status
-   * @metohd getIsSubmitDisabled
+   * @metohd updateIsSubmitDisabled
    */
-  getIsSubmitDisabled: function () {
-    var isSubmitDisabled = this.get('servicesMasters').someProperty('isHostNameValid', false);
-    this.set('submitDisabled', isSubmitDisabled);
-    return isSubmitDisabled;
+  updateIsSubmitDisabled: function () {
+    var self = this;
+
+    if (self.thereIsNoMasters()) {
+      return false;
+    }
+
+    if (App.supports.serverRecommendValidate) {
+      self.set('submitDisabled', true);
+
+      // reset previous recommendations
+      App.router.set('installerController.recommendations', null);
+
+      if (self.get('servicesMasters').length === 0) {
+        return;
+      }
+
+      var isSubmitDisabled = this.get('servicesMasters').someProperty('isHostNameValid', false);
+      if (!isSubmitDisabled) {
+        self.recommendAndValidate();
+      }
+    } else {
+      var isSubmitDisabled = this.get('servicesMasters').someProperty('isHostNameValid', false);
+      self.set('submitDisabled', isSubmitDisabled);
+      return isSubmitDisabled;
+    }
   }.observes('servicesMasters.@each.selectedHost', 'servicesMasters.@each.isHostNameValid'),
 
   /**
+   * Send AJAX request to validate current host layout
+   * @param blueprint - blueprint for validation (can be with/withour slave/client components)
+   */
+  validate: function(blueprint, callback) {
+    var self = this;
+
+    var selectedServices = App.StackService.find().filterProperty('isSelected').mapProperty('serviceName');
+    var installedServices = App.StackService.find().filterProperty('isInstalled').mapProperty('serviceName');
+    var services = installedServices.concat(selectedServices).uniq();
+
+    var hostNames = self.get('hosts').mapProperty('host_name');
+
+    App.ajax.send({
+      name: 'config.validations',
+      sender: self,
+      data: {
+        stackVersionUrl: App.get('stackVersionURL'),
+        hosts: hostNames,
+        services: services,
+        validate: 'host_groups',
+        recommendations: blueprint
+      },
+      success: 'updateValidationsSuccessCallback'
+    }).
+      retry({
+        times: App.maxRetries,
+        timeout: App.timeout
+      }).
+      then(function() {
+        if (callback) {
+          callback();
+        }
+      }, function () {
+        App.showReloadPopup();
+        console.log('Load validations failed');
+      }
+    );
+  },
+
+/**
+  * Success-callback for validations request
+  * @param {object} data
+  * @method updateValidationsSuccessCallback
+  */
+  updateValidationsSuccessCallback: function (data) {
+    var self = this;
+
+    this.set('generalErrorMessages', []);
+    this.set('generalWarningMessages', []);
+    this.get('servicesMasters').setEach('warnMessage', null);
+    this.get('servicesMasters').setEach('errorMessage', null);
+    var anyErrors = false;
+
+    var validationData = validationUtils.filterNotInstalledComponents(data);
+    validationData.filterProperty('type', 'host-component').forEach(function(item) {
+      var master = self.get('servicesMasters').find(function(m) {
+        return m.component_name === item['component-name'] && m.selectedHost === item.host;
+      });
+      if (master) {
+        if (item.level === 'ERROR') {
+          anyErrors = true;
+          master.set('errorMessage', item.message);
+        } else if (item.level === 'WARN') {
+          master.set('warnMessage', item.message);
+        }
+      } else {
+        var details = " (" + item['component-name'] + " on " + item.host + ")";
+        if (item.level === 'ERROR') {
+          anyErrors = true;
+          self.get('generalErrorMessages').push(item.message + details);
+        } else if (item.level === 'WARN') {
+          self.get('generalWarningMessages').push(item.message + details);
+        }
+      }
+    });
+
+    this.set('submitDisabled', anyErrors);
+  },
+
+  /**
+   * Composes selected values of comboboxes into blueprint format
+   */
+  getCurrentBlueprint: function() {
+    var self = this;
+
+    var res = {
+      blueprint: { host_groups: [] },
+      blueprint_cluster_binding: { host_groups: [] }
+    };
+
+    var mapping = self.get('masterHostMapping');
+
+    var i = 0;
+    mapping.forEach(function(item) {
+      i += 1;
+      var group_name = 'host-group-' + i;
+
+      var host_group = {
+        name: group_name,
+        components: item.masterServices.map(function(master) {
+          return { name: master.component_name };
+        })
+      };
+
+      var binding = {
+        name: group_name,
+        hosts: [ { fqdn: item.host_name } ]
+      }
+
+      res.blueprint.host_groups.push(host_group);
+      res.blueprint_cluster_binding.host_groups.push(binding);
+    });
+
+    return res;
+  },
+
+/**
    * Clear controller data (hosts, masters etc)
    * @method clearStep
    */
@@ -232,12 +382,19 @@ App.WizardStep5Controller = Em.Controller.extend({
     self.get('addableComponents').forEach(function (componentName) {
       self.updateComponent(componentName);
     }, self);
-    if (!self.get("selectedServicesMasters").filterProperty('isInstalled', false).length) {
+    if (self.thereIsNoMasters()) {
       console.log('no master components to add');
       App.router.send('next');
     }
   },
 
+  /**
+  * Returns true if there is no new master components which need assigment to host
+  */
+  thereIsNoMasters: function() {
+    return !this.get("selectedServicesMasters").filterProperty('isInstalled', false).length;
+  },
+
   /**
    * Used to set showAddControl flag for installer wizard
    * @method updateComponent
@@ -305,11 +462,13 @@ App.WizardStep5Controller = Em.Controller.extend({
   /**
    * Get recommendations info from API
    * @return {undefined}
+   * @param function(componentInstallationobjects, this) callback
+   * @param bool includeMasters
    */
-  loadComponentsRecommendationsFromServer: function(callback) {
+  loadComponentsRecommendationsFromServer: function(callback, includeMasters) {
     var self = this;
 
-    if (App.router.get('installerController.recommendations') !== undefined) {
+    if (App.router.get('installerController.recommendations')) {
       // Don't do AJAX call if recommendations has been already received
       // But if user returns to previous step (selecting services), stored recommendations will be cleared in routers' next handler and AJAX call will be made again
       callback(self.createComponentInstallationObjects(), self);
@@ -320,14 +479,21 @@ App.WizardStep5Controller = Em.Controller.extend({
 
       var hostNames = self.get('hosts').mapProperty('host_name');
 
+      var data = {
+        stackVersionUrl: App.get('stackVersionURL'),
+        hosts: hostNames,
+        services: services,
+        recommend: 'host_groups'
+      };
+
+      if (includeMasters) {
+        data.recommendations = self.getCurrentBlueprint();
+      }
+
       return App.ajax.send({
-        name: 'wizard.step5.recommendations',
+        name: 'wizard.loadrecommendations',
         sender: self,
-        data: {
-          stackVersionUrl: App.get('stackVersionURL'),
-          hosts: hostNames,
-          services: services
-        },
+        data: data,
         success: 'loadRecommendationsSuccessCallback'
       }).
         retry({
@@ -554,6 +720,7 @@ App.WizardStep5Controller = Em.Controller.extend({
         componentObj.set("showRemoveControl", showRemoveControl);
       }
       componentObj.set('isHostNameValid', true);
+
       result.push(componentObj);
     }, this);
     result = this.sortComponentsByServiceName(result);
@@ -842,15 +1009,40 @@ App.WizardStep5Controller = Em.Controller.extend({
     return true;
   },
 
+  recommendAndValidate: function(callback) {
+    var self = this;
+
+    // load recommendations with partial request
+    self.loadComponentsRecommendationsFromServer(function() {
+      // For validation use latest received recommendations because ir contains current master layout and recommended slave/client layout
+      self.validate(App.router.get('installerController.recommendations'), function() {
+        if (callback) {
+          callback();
+        }
+      });
+    }, true);
+  },
+
   /**
    * Submit button click handler
    * @metohd submit
    */
   submit: function () {
-    this.getIsSubmitDisabled();
-    if (!this.get('submitDisabled')) {
-      App.router.send('next');
+    var self = this;
+
+    var goNextStepIfValid = function() {
+      if (!self.get('submitDisabled')) {
+        App.router.send('next');
+      }
+    };
+
+    if (App.supports.serverRecommendValidate ) {
+      self.recommendAndValidate(function() {
+        goNextStepIfValid();
+      });
+    } else {
+      self.updateIsSubmitDisabled();
+      goNextStepIfValid();
     }
   }
-
 });

+ 261 - 6
ambari-web/app/controllers/wizard/step6_controller.js

@@ -19,6 +19,8 @@
 var App = require('app');
 var db = require('utils/db');
 var stringUtils = require('utils/string_utils');
+var blueprintUtils = require('utils/blueprint');
+var validationUtils = require('utils/validator');
 
 /**
  * By Step 6, we have the following information stored in App.db and set on this
@@ -57,6 +59,12 @@ App.WizardStep6Controller = Em.Controller.extend({
    */
   isLoaded: false,
 
+  /**
+   * Define state for submit button
+   * @type {bool}
+   */
+  submitDisabled: true,
+
   /**
    * Check if <code>addHostWizard</code> used
    * @type {bool}
@@ -85,6 +93,16 @@ App.WizardStep6Controller = Em.Controller.extend({
     return this.get('content.services').filterProperty('isInstalled').mapProperty('serviceName');
   }.property('content.services').cacheable(),
 
+  /**
+   * Validation error messages which don't related with any master
+   */
+  generalErrorMessages: [],
+
+  /**
+   * Validation warning messages which don't related with any master
+   */
+  generalWarningMessages: [],
+
   /**
    * Verify condition that at least one checkbox of each component was checked
    * @method clearError
@@ -149,6 +167,7 @@ App.WizardStep6Controller = Em.Controller.extend({
     var name = Em.get(event, 'context.name');
     if (name) {
       this.setAllNodes(name, true);
+      this.callValidation();
     }
   },
 
@@ -161,6 +180,7 @@ App.WizardStep6Controller = Em.Controller.extend({
     var name = Em.get(event, 'context.name');
     if (name) {
       this.setAllNodes(name, false);
+      this.callValidation();
     }
   },
 
@@ -251,6 +271,8 @@ App.WizardStep6Controller = Em.Controller.extend({
     this.render();
     if (this.get('content.skipSlavesStep')) {
       App.router.send('next');
+    } else {
+      this.callValidation();
     }
   },
 
@@ -365,7 +387,6 @@ App.WizardStep6Controller = Em.Controller.extend({
 
         var clientComponents = App.get('components.clients');
 
-
         hostsObj.forEach(function (host) {
           var checkboxes = host.get('checkboxes');
           checkboxes.forEach(function (checkbox) {
@@ -448,14 +469,249 @@ App.WizardStep6Controller = Em.Controller.extend({
     return this.get('content.masterComponentHosts').filterProperty('hostName', hostName).mapProperty('component');
   },
 
+  callValidation: function(successCallback) {
+    var self = this;
+    if (App.supports.serverRecommendValidate) {
+      self.callServerSideValidation(successCallback);
+    } else {
+      var res = self.callClientSideValidation();
+      self.set('submitDisabled', !res);
+      if (res && successCallback) {
+        successCallback();
+      }
+    }
+  },
 
   /**
-   * Validate form. Return do we have errors or not
-   * @return {bool}
-   * @method validate
+   * Update submit button status
+   * @metohd callServerSideValidation
    */
-  validate: function () {
+  callServerSideValidation: function (successCallback) {
+    var self = this;
+    self.set('submitDisabled', true);
+
+    var selectedServices = App.StackService.find().filterProperty('isSelected').mapProperty('serviceName');
+    var installedServices = App.StackService.find().filterProperty('isInstalled').mapProperty('serviceName');
+    var services = installedServices.concat(selectedServices).uniq();
+
+    var hostNames = self.get('hosts').mapProperty('hostName');
+    var slaveBlueprint = self.getCurrentBlueprint();
+    var masterBlueprint = null;
+    var invisibleSlaves = App.StackServiceComponent.find().filterProperty("isSlave").filterProperty("isShownOnInstallerSlaveClientPage", false).mapProperty("componentName");
 
+    if (this.get('isInstallerWizard') || this.get('isAddServiceWizard')) {
+      masterBlueprint = App.router.get('wizardStep5Controller').getCurrentBlueprint();
+
+      var invisibleMasters = [];
+      if (this.get('isInstallerWizard')) {
+        invisibleMasters = App.StackServiceComponent.find().filterProperty("isMaster").filterProperty("isShownOnInstallerAssignMasterPage", false).mapProperty("componentName");
+      } else if (this.get('isAddServiceWizard')) {
+        invisibleMasters = App.StackServiceComponent.find().filterProperty("isMaster").filterProperty("isShownOnAddServiceAssignMasterPage", false).mapProperty("componentName");
+      }
+
+      var selectedClientComponents = self.get('content.clients').mapProperty('component_name');
+      var alreadyInstalledClients = App.get('components.clients').reject(function(c) {
+        return selectedClientComponents.contains(c);
+      });
+
+      var invisibleComponents = invisibleMasters.concat(invisibleSlaves).concat(alreadyInstalledClients);
+
+      var invisibleBlueprint = blueprintUtils.filterByComponents(App.router.get('installerController.recommendations'), invisibleComponents);
+      masterBlueprint = blueprintUtils.mergeBlueprints(masterBlueprint, invisibleBlueprint);
+    } else if (this.get('isAddHostWizard')) {
+      masterBlueprint = self.getMasterSlaveBlueprintForAddHostWizard();
+      hostNames = hostNames.concat(App.Host.find().mapProperty("hostName")).uniq();
+      slaveBlueprint = blueprintUtils.addComponentsToBlueprint(slaveBlueprint, invisibleSlaves);
+    }
+
+    App.ajax.send({
+      name: 'config.validations',
+      sender: self,
+      data: {
+        stackVersionUrl: App.get('stackVersionURL'),
+        hosts: hostNames,
+        services: services,
+        validate: 'host_groups',
+        recommendations: blueprintUtils.mergeBlueprints(masterBlueprint, slaveBlueprint)
+      },
+      success: 'updateValidationsSuccessCallback'
+    }).
+      retry({
+        times: App.maxRetries,
+        timeout: App.timeout
+      }).
+      then(function() {
+        if (!self.get('submitDisabled') && successCallback) {
+          successCallback();
+        }
+      }, function () {
+        App.showReloadPopup();
+        console.log('Load validations failed');
+      }
+    );
+  },
+
+  /**
+   * Success-callback for validations request
+   * @param {object} data
+   * @method updateValidationsSuccessCallback
+   */
+  updateValidationsSuccessCallback: function (data) {
+    var self = this;
+    //data = JSON.parse(data); // temporary fix
+
+    var clientComponents = App.get('components.clients');
+
+    this.set('generalErrorMessages', []);
+    this.set('generalWarningMessages', []);
+    this.get('hosts').setEach('warnMessages', []);
+    this.get('hosts').setEach('errorMessages', []);
+    this.get('hosts').setEach('anyMessage', false);
+    this.get('hosts').forEach(function(host) {
+      host.checkboxes.setEach('hasWarnMessage', false);
+      host.checkboxes.setEach('hasErrorMessage', false);
+    });
+    var anyErrors = false;
+    var anyGeneralClientErrors = false; // any error/warning for any client component (under "CLIENT" alias)
+
+    var validationData = validationUtils.filterNotInstalledComponents(data);
+    validationData.filterProperty('type', 'host-component').forEach(function(item) {
+      var checkboxWithIssue = null;
+      var isGeneralClientValidationItem = clientComponents.contains(item['component-name']); // it is an error/warning for any client component (under "CLIENT" alias)
+      var host = self.get('hosts').find(function(h) {
+        return h.hostName === item.host && h.checkboxes.some(function(checkbox) {
+          var isClientComponent = checkbox.component === "CLIENT" && isGeneralClientValidationItem;
+          if (checkbox.component === item['component-name'] || isClientComponent) {
+            checkboxWithIssue = checkbox;
+            return true;
+          } else {
+            return false;
+          }
+        });
+      });
+      if (host) {
+        host.set('anyMessage', true);
+
+        if (item.level === 'ERROR') {
+          anyErrors = true;
+          host.get('errorMessages').push(item.message);
+          checkboxWithIssue.set('hasErrorMessage', true);
+        } else if (item.level === 'WARN') {
+          host.get('warnMessages').push(item.message);
+          checkboxWithIssue.set('hasWarnMessage', true);
+        }
+      } else {
+        var component;
+        if (isGeneralClientValidationItem) {
+          if (!anyGeneralClientErrors) {
+            anyGeneralClientErrors = true;
+            component = "Client";
+          }
+        } else {
+          component = item['component-name'];
+        }
+
+        if (component || !item['component-name']) {
+          var details = "";
+          if (component) {
+            details += " for " + component + " component";
+          }
+          if (item.host) {
+            details += " " + item.host;
+          }
+
+          if (item.level === 'ERROR') {
+            anyErrors = true;
+            self.get('generalErrorMessages').push(item.message + details);
+          } else if (item.level === 'WARN') {
+            self.get('generalWarningMessages').push(item.message + details);
+          }
+        }
+      }
+    });
+
+    this.set('submitDisabled', anyErrors);
+  },
+
+  /**
+   * Composes selected values of comboboxes into blueprint format
+   */
+  getCurrentBlueprint: function() {
+    var self = this;
+
+    var res = {
+      blueprint: { host_groups: [] },
+      blueprint_cluster_binding: { host_groups: [] }
+    };
+
+    var clientComponents = self.get('content.clients').mapProperty('component_name');
+    var mapping = self.get('hosts');
+
+    var i = 0;
+    mapping.forEach(function(item) {
+      i += 1;
+      var group_name = 'host-group-' + i;
+
+      var host_group = {
+        name: group_name,
+        components: item.checkboxes.filterProperty('checked', true).map(function(checkbox) {
+          if (checkbox.component === "CLIENT") {
+            return clientComponents.map(function(client) {
+              return { name: client };
+            });
+          } else {
+            return { name: checkbox.component };
+          }
+        })
+      };
+
+      host_group.components = [].concat.apply([], host_group.components);
+
+      var binding = {
+        name: group_name,
+        hosts: [ { fqdn: item.hostName } ]
+      }
+
+      res.blueprint.host_groups.push(host_group);
+      res.blueprint_cluster_binding.host_groups.push(binding);
+    });
+
+    return res;
+  },
+
+  getMasterSlaveBlueprintForAddHostWizard: function() {
+    var components = App.HostComponent.find();
+    var hosts = components.mapProperty("hostName").uniq();
+
+    var res = {
+      blueprint: { host_groups: [] },
+      blueprint_cluster_binding: { host_groups: [] }
+    };
+
+    var i = 0;
+    hosts.forEach(function(host) {
+      i += 1;
+      var group_name = 'host-group-' + i;
+
+      res.blueprint.host_groups.push({
+        name: group_name,
+        components: components.filterProperty("hostName", host).mapProperty("componentName").map(function(c) { return { name: c }; })
+      });
+
+      res.blueprint_cluster_binding.host_groups.push({
+        name: group_name,
+        hosts: [ { fqdn: host } ]
+      });
+    });
+    return res;
+  },
+
+  /**
+   * callClientSideValidation form. Return do we have errors or not
+   * @return {bool}
+   * @method callClientSideValidation
+   */
+  callClientSideValidation: function () {
     if (this.get('isAddHostWizard')) {
       return this.validateEachHost(Em.I18n.t('installer.step6.error.mustSelectOneForHost'));
     }
@@ -546,5 +802,4 @@ App.WizardStep6Controller = Em.Controller.extend({
 
     return !isError;
   }
-
 });

+ 2 - 2
ambari-web/app/routes/add_host_routes.js

@@ -192,14 +192,14 @@ module.exports = App.WizardRoute.extend({
       var addHostController = router.get('addHostController');
       var wizardStep6Controller = router.get('wizardStep6Controller');
 
-      if (wizardStep6Controller.validate()) {
+      wizardStep6Controller.callValidation(function() {
         addHostController.saveSlaveComponentHosts(wizardStep6Controller);
         if(App.supports.hostOverrides){
           router.transitionTo('step4');
         }else{
           router.transitionTo('step5');
         }
-      }
+      });
     }
   }),
 

+ 2 - 2
ambari-web/app/routes/add_service_routes.js

@@ -168,13 +168,13 @@ module.exports = App.WizardRoute.extend({
       var addServiceController = router.get('addServiceController');
       var wizardStep6Controller = router.get('wizardStep6Controller');
 
-      if (wizardStep6Controller.validate()) {
+      wizardStep6Controller.callValidation(function() {
         addServiceController.saveSlaveComponentHosts(wizardStep6Controller);
         addServiceController.get('content').set('serviceConfigProperties', null);
         addServiceController.setDBProperty('serviceConfigProperties', null);
         addServiceController.setDBProperty('groupsToDelete', []);
         router.transitionTo('step4');
-      }
+      });
     }
   }),
 

+ 1 - 1
ambari-web/app/routes/installer.js

@@ -296,7 +296,7 @@ module.exports = Em.Route.extend({
       var wizardStep6Controller = router.get('wizardStep6Controller');
       var wizardStep7Controller = router.get('wizardStep7Controller');
 
-      if (wizardStep6Controller.validate()) {
+      if (!wizardStep6Controller.get('submitDisabled')) {
         controller.saveSlaveComponentHosts(wizardStep6Controller);
         controller.get('content').set('serviceConfigProperties', null);
         controller.setDBProperty('serviceConfigProperties', null);

+ 19 - 24
ambari-web/app/styles/application.less

@@ -1184,30 +1184,18 @@ h1 {
   .common-config-category {
     .action {
       cursor: pointer;
-    }
-    .btn-final .icon-lock {
-      color: grey;
-    }
-    .btn-final.active .icon-lock {
-      color: blue;
-    }
-    .btn-final.active[disabled] { //copied from Bootstrap .btn.active
-      background-color: #e6e6e6;
-      background-color: #d9d9d9 \9;
-      background-image: none;
-      outline: 0;
-      -webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05);
-      -moz-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05);
-      box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05);
-    }
-    .icon-plus-sign {
-      color: #5AB400;
-    }
-    .icon-minus-sign {
-      color: #FF4B4B;
-    }
-    .icon-undo {
-      color: rgb(243, 178, 11);
+      .icon-plus-sign {
+        color: #5AB400;
+        margin-right: 2px;
+      }
+      .icon-minus-sign {
+        color: #FF4B4B;
+        margin-right: 2px;
+      }
+      .icon-undo {
+        color: rgb(243, 178, 11);
+        margin-right: 2px;
+      }
     }
   }
   .capacity-scheduler {
@@ -4047,6 +4035,9 @@ table.graphs {
 .assign-masters {
   .select-hosts {
     white-space: nowrap;
+    .help-block {
+      white-space: normal;
+    }
   }
 
   label.host-name {
@@ -6792,3 +6783,7 @@ i.icon-asterisks {
     width: 95%;
   }
 }
+
+.table td.no-borders { border-top: none; }
+.table td.error { background-color: #f2dede; }
+.table td.warning { background-color: #fcf8e3; }

+ 1 - 0
ambari-web/app/styles/apps.less

@@ -260,6 +260,7 @@
   .table-striped tbody .even th {
     background-color: #fff;
   }
+
   .sorting_asc { background: url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/bAIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgICAwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAEwATAwERAAIRAQMRAf/EAHgAAAMBAQAAAAAAAAAAAAAAAAAFCAYKAQACAQUAAAAAAAAAAAAAAAAABQMCBAYHCBAAAQUAAQMEAwAAAAAAAAAAAwECBAUGABESByExIghBMxQRAAIBAwMDAwUAAAAAAAAAAAECAwAEBRESBiExUUHhB2GBIhMU/9oADAMBAAIRAxEAPwDvA8k+Qc54sxGj32qlNi0ucrjTj/JqGlmROyJXQ2u/bOsZTmBExPd70/HXmQcW41lOX5+145h0L391KEHhR3Z28Ii6sx9AKgubiO1gaeU6Io19h9TUg/S/7eP+wia3NbBIFbuqiyn3VTCjIMArHHTJarEDGGiNU8vOKVsc7/VxBuGR3yV683X86/Cq/GpssrhP2S8emiSKRm1JS5VfyLH0WfQug7KwZR0CilWHy39++ObQTgkgeV9ux+xq9uc6U8pLfZzP6mClZpKWrvq1DilJAt4Mewh/0hRyBOsaUMoVKLvXtVU6t6+nL/HZTJYi4/rxU81tdbSu+N2Rtp7jcpB0OnUa9aoeOOVdsgDL4I1pFS+NPHmcsQ2+fw+UpLWOwwwWNVQ1kCaIcgaiONkmLGEZrDDXtcnXo5PfjC+5VybKWrWWSyF5cWbEEpJNI6kqdQSrMRqD1B9KjS2t423xoqt5AArb8QVPRwoo4UUcKK//2Q==) no-repeat right 50%; }
   .sorting_desc { background: url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/bAIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgICAwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAEwATAwERAAIRAQMRAf/EAIEAAAIDAQAAAAAAAAAAAAAAAAAGBwgJCgEBAAIDAQAAAAAAAAAAAAAAAAMFBAYHCBAAAAUDAwMFAAAAAAAAAAAAAQIDBAUABgcSNTYRFQgTZFUWZhEAAAQEAggGAwAAAAAAAAAAAAECAxEhBAYSMjFBYRMzFDQFUZFSYmMHJFRk/9oADAMBAAIRAxEAPwDv4oAKACgCKc1tMmusb3Eph6cSgsgx7fucEZxGRks2llGIGVWgVm8q1dt0+6ogKaapSgdNbQPXTqAdwsN602bopk3vTnUW24rduwccbU2S5E8Sm1JM92czSZwNOKUYDFrCqTp1corDUFMpEcYap+Ipb4P5O8n81y9xXXlG50yY+thR3AEivqFvRDmduvSUrhuLtrFNXqCFvJm1LAQ5RMuchB6gBy13f7+tP6lsOipuz2jSGdy1ZJeNzmXnEtU+pWFTikmbxyTEjgglKKZpMU3ZanudYtTtSr8dMoYSKKvKMte0aUV5YGxgoASbD2iQ4Tyi6uB7Rvz/AHD9R8r7/wBWr64uta6/pKfq+JwUZP5/1/hwCFjIeTMrLo0np93q2xDtVCJh/9k=) no-repeat right 50%; }
   .sorting { background: url(data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAZAAA/+4ADkFkb2JlAGTAAAAAAf/bAIQAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQICAgICAgICAgICAwMDAwMDAwMDAwEBAQEBAQECAQECAgIBAgIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD/8AAEQgAEwATAwERAAIRAQMRAf/EAGgAAAIDAQAAAAAAAAAAAAAAAAUHAAYICgEBAQAAAAAAAAAAAAAAAAAAAAEQAAEEAQIFAgcAAAAAAAAAAAECAwQFABEGIRI0NQcTFDFBMmNUZRYRAQEBAQAAAAAAAAAAAAAAAAABEUH/2gAMAwEAAhEDEQA/AO93cd/XbXpLC9tHQ1Dr46nljUBby/gzGZB+p+Q6QhA+ZOApfDnllW/ha1tv6Ee7iyH5kRlvlbTIqHndWkNJ0HO7XFQbWeJUkpUeOpySrZh65UUnyFUW1ztaexRmIbaPyzoLE6vg2UWW9GC1e0XHnsSGEqfQohCwApK9OIGuAjfBP9VuG0m39vGqINVUe4r2xF21TVsuXZOI9N9lMmLBYkttQ21auBKhqtSUngCMkW5xqjKiYASh6SR2Tulr2HpOvf6j9p+V9/mwDeB//9k=) no-repeat right 50%; }

+ 10 - 1
ambari-web/app/templates/wizard/step5.hbs

@@ -23,6 +23,12 @@
     {{{view.coHostedComponentText}}}
   {{/if}}
 </div>
+{{#each msg in controller.generalErrorMessages}}
+  <div class="alert alert-error">{{msg}}</div>
+{{/each}}
+{{#each msg in controller.generalWarningMessages}}
+    <div class="alert alert-warning">{{msg}}</div>
+{{/each}}
 {{#if controller.isLoaded}}
   <div class="assign-masters row-fluid">
     <div class="select-hosts span7">
@@ -56,7 +62,7 @@
                         {{selectedHost}}<i class="icon-asterisks">&#10037;</i>
                       </div>
                     {{else}}
-                      <div class="control-group">
+                      <div {{bindAttr class="errorMessage:error: warnMessage:warning: :control-group"}}>
                         {{#if view.shouldUseInputs}}
                           {{view App.InputHostView
                           componentBinding="this"
@@ -74,6 +80,9 @@
                         {{#if showRemoveControl}}
                           {{view App.RemoveControlView componentNameBinding="component_name" serviceComponentIdBinding="serviceComponentId"}}
                         {{/if}}
+
+                        <span class="help-block">{{warnMessage}}</span>
+                        <span class="help-block">{{errorMessage}}</span>
                       </div>
                     {{/if}}
                   </div>

+ 19 - 3
ambari-web/app/templates/wizard/step6.hbs

@@ -23,9 +23,15 @@
   {{#if errorMessage}}
     <div class="alert alert-error">{{errorMessage}}</div>
   {{/if}}
+  {{#each msg in controller.generalErrorMessages}}
+      <div class="alert alert-error">{{msg}}</div>
+  {{/each}}
+  {{#each msg in controller.generalWarningMessages}}
+      <div class="alert alert-warning">{{msg}}</div>
+  {{/each}}
 
   <div class="pre-scrollable">
-    <table class="table table-striped" id="component_assign_table">
+    <table class="table" id="component_assign_table">
       <thead>
       <tr>
         <th>{{t common.host}}</th>
@@ -52,7 +58,7 @@
               {{/if}}
             {{/view}}
             {{#each checkbox in host.checkboxes}}
-              <td>
+              <td {{bindAttr class="checkbox.hasErrorMessage:error checkbox.hasWarnMessage:warning"}}>
                 <label class="checkbox">
                   <input {{bindAttr checked = "checkbox.checked" disabled="checkbox.isDisabled"}} {{action "checkboxClick" checkbox target="view" }}
                           type="checkbox"/>{{checkbox.title}}
@@ -60,6 +66,16 @@
               </td>
             {{/each}}
           </tr>
+          <tr {{bindAttr class="host.anyMessage::hidden"}}>
+            <td {{bindAttr colspan="view.columnCount"}} class="no-borders">
+              {{#each errorMsg in host.errorMessages}}
+                  <div class="alert alert-error">{{errorMsg}}</div>
+              {{/each}}
+              {{#each warnMsg in host.warnMessages}}
+                <div class="alert alert-warning">{{warnMsg}}</div>
+              {{/each}}
+            </td>
+          </tr>
         {{/each}}
       {{/if}}
       </tbody>
@@ -81,6 +97,6 @@
   </div>
   <div class="btn-area">
     <a class="btn" {{action back}}>&larr; {{t common.back}}</a>
-    <a class="btn btn-success pull-right" {{action next}}>{{t common.next}} &rarr;</a>
+    <a class="btn btn-success pull-right" {{bindAttr disabled="submitDisabled"}} {{action next}}>{{t common.next}} &rarr;</a>
   </div>
 </div>

+ 33 - 7
ambari-web/app/utils/ajax/ajax.js

@@ -338,7 +338,7 @@ var urls = {
       }
     }
   },
-  
+
   'cancel.background.operation' : {
     'real' : '/clusters/{clusterName}/requests/{requestId}',
     'mock' : '',
@@ -1232,21 +1232,30 @@ var urls = {
     }
   },
 
-  'wizard.step5.recommendations': {
+
+  'wizard.loadrecommendations': {
     'real': '{stackVersionUrl}/recommendations',
     'mock': '/data/stacks/HDP-2.1/recommendations.json',
     'type': 'POST',
     'format': function (data) {
+      var q = {
+        hosts: data.hosts,
+        services: data.services,
+        recommend: data.recommend
+      };
+
+      if (data.recommendations) {
+        q.recommendations = data.recommendations;
+      }
+
       return {
-        data: JSON.stringify({
-          hosts: data.hosts,
-          services: data.services,
-          recommend: "host_groups"
-        })
+        data: JSON.stringify(q)
       }
     }
   },
 
+
+  // TODO: merge with wizard.loadrecommendations query
   'wizard.step7.loadrecommendations.configs': {
     'real': '{stackVersionUrl}/recommendations',
     'mock': '/data/stacks/HDP-2.1/recommendations_configs.json',
@@ -1263,6 +1272,23 @@ var urls = {
     }
   },
 
+  'config.validations': {
+    'real': '{stackVersionUrl}/validations',
+    'mock': '/data/stacks/HDP-2.1/validations.json',
+    'type': 'POST',
+    'format': function (data) {
+      return {
+          data: JSON.stringify({
+            hosts: data.hosts,
+            services: data.services,
+            validate: data.validate,
+            recommendations: data.recommendations
+        })
+      }
+    }
+  },
+
+
   'preinstalled.checks': {
     'real':'/requests',
     'mock':'',

+ 189 - 0
ambari-web/app/utils/blueprint.js

@@ -0,0 +1,189 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+module.exports = {
+  mergeBlueprints: function(masterBlueprint, slaveBlueprint) {
+    var self = this;
+
+    // Check edge cases
+    if (!slaveBlueprint && !masterBlueprint) {
+      throw 'slaveBlueprint or masterBlueprint should not be empty';
+    } else if (slaveBlueprint && !masterBlueprint) {
+      return slaveBlueprint;
+    } else if (!slaveBlueprint && masterBlueprint) {
+      return masterBlueprint;
+    }
+
+    // Work with main case (both blueprint are presented)
+    var matches = self.matchGroups(masterBlueprint, slaveBlueprint);
+
+    var res = {
+      blueprint: { host_groups: [] },
+      blueprint_cluster_binding: { host_groups: [] }
+    };
+
+    var i = 0;
+    matches.forEach(function(match){
+      i += 1;
+      var group_name = 'host-group-' + i;
+
+      var masterComponents = self.getComponentsFromBlueprintByGroupName(masterBlueprint, match.g1);
+      var slaveComponents = self.getComponentsFromBlueprintByGroupName(slaveBlueprint, match.g2);
+
+      res.blueprint.host_groups.push({
+        name: group_name,
+        components: masterComponents.concat(slaveComponents)
+      });
+
+      res.blueprint_cluster_binding.host_groups.push({
+        name: group_name,
+        hosts: self.getHostsFromBlueprintByGroupName(match.g1 ? masterBlueprint : slaveBlueprint, match.g1 ? match.g1 : match.g2)
+      });
+    });
+    return res;
+  },
+
+  getHostsFromBlueprint: function(blueprint) {
+    return blueprint.blueprint_cluster_binding.host_groups.mapProperty("hosts").reduce(function(prev, curr){ return prev.concat(curr); }, []).mapProperty("fqdn");
+  },
+
+  getHostsFromBlueprintByGroupName: function(blueprint, groupName) {
+    if (groupName) {
+      var group = blueprint.blueprint_cluster_binding.host_groups.find(function(g) {
+        return g.name === groupName;
+      });
+
+      if (group) {
+        return group.hosts;
+      }
+    }
+    return [];
+  },
+
+  getComponentsFromBlueprintByGroupName: function(blueprint, groupName) {
+    if (groupName) {
+      var group = blueprint.blueprint.host_groups.find(function(g) {
+        return g.name === groupName;
+      });
+
+      if (group) {
+        return group.components;
+      }
+    }
+    return [];
+  },
+
+  matchGroups: function(masterBlueprint, slaveBlueprint) {
+    var self = this;
+    var res = [];
+
+    var groups1 = masterBlueprint.blueprint_cluster_binding.host_groups;
+    var groups2 = slaveBlueprint.blueprint_cluster_binding.host_groups;
+
+    var groups1_used = groups1.map(function() { return false; });
+    var groups2_used = groups2.map(function() { return false; });
+
+    self.matchGroupsWithLeft(groups1, groups2, groups1_used, groups2_used, res, false);
+    self.matchGroupsWithLeft(groups2, groups1, groups2_used, groups1_used, res, true);
+
+    return res;
+  },
+
+  matchGroupsWithLeft: function(groups1, groups2, groups1_used, groups2_used, res, inverse) {
+    for (var i = 0; i < groups1.length; i++) {
+      if (groups1_used[i]) {
+        continue;
+      }
+
+      var group1 = groups1[i];
+      groups1_used[i] = true;
+
+      var group2 = groups2.find(function(g2, index) {
+        if (group1.hosts.length != g2.hosts.length) {
+          return false;
+        }
+
+        for (var gi = 0; gi < group1.hosts.length; gi++) {
+          if (group1.hosts[gi].fqdn != g2.hosts[gi].fqdn) {
+            return false;
+          }
+        }
+
+        groups2_used[index] = true;
+        return true;
+      });
+
+      var item = {};
+
+      if (inverse) {
+        item.g2 = group1.name;
+        if (group2) {
+          item.g1 = group2.name;
+        }
+      } else {
+        item.g1 = group1.name;
+        if (group2) {
+          item.g2 = group2.name;
+        }
+      }
+      res.push(item);
+    }
+  },
+
+  /**
+   * Remove from blueprint all components expect given components
+   * @param blueprint
+   * @param [string] components
+   */
+  filterByComponents: function(blueprint, components) {
+    var res = JSON.parse(JSON.stringify(blueprint))
+    var emptyGroups = [];
+
+    for (var i = 0; i < res.blueprint.host_groups.length; i++) {
+      res.blueprint.host_groups[i].components = res.blueprint.host_groups[i].components.filter(function(c) {
+        return components.contains(c.name);
+      });
+
+      if (res.blueprint.host_groups[i].components.length == 0) {
+        emptyGroups.push(res.blueprint.host_groups[i].name);
+      }
+    }
+
+    res.blueprint.host_groups = res.blueprint.host_groups.filter(function(g) {
+      return !emptyGroups.contains(g.name);
+    });
+
+    res.blueprint_cluster_binding.host_groups = res.blueprint_cluster_binding.host_groups.filter(function(g) {
+      return !emptyGroups.contains(g.name);
+    });
+
+    return res;
+  },
+
+  addComponentsToBlueprint: function(blueprint, components) {
+    var res = JSON.parse(JSON.stringify(blueprint))
+
+    res.blueprint.host_groups.forEach(function(group) {
+      components.forEach(function(component) {
+        group.components.push({ name: component });
+      });
+    });
+
+    return res;
+  }
+};

+ 11 - 0
ambari-web/app/utils/validator.js

@@ -167,5 +167,16 @@ module.exports = {
     }
     if (/^[\?\|\*\!,]/.test(value)) return false;
     return /^((\.\*?)?([\w\[\]\?\-_,\|\*\!\{\}]*)?)+(\.\*?)?$/g.test(value) && (checkPair(['[',']'])) && (checkPair(['{','}']));
+  },
+
+  /**
+  * Remove validation messages for components which are already installed
+  */
+  filterNotInstalledComponents: function(validationData) {
+    var hostComponents = App.HostComponent.find();
+    return validationData.resources[0].items.filter(function(item) {
+      // true is there is no host with this component
+      return hostComponents.filterProperty("componentName", item["component-name"]).filterProperty("hostName", item.host).length === 0;
+    });
   }
 };

+ 11 - 1
ambari-web/app/views/wizard/step6_view.js

@@ -90,7 +90,17 @@ App.WizardStep6View = App.TableView.extend({
     var checkbox = e.context;
     checkbox.toggleProperty('checked');
     this.get('controller').checkCallback(checkbox.component);
-  }
+    this.get('controller').callValidation();
+  },
+
+  columnCount: function() {
+    var hosts = this.get('controller.hosts');
+    if  (hosts && hosts.length > 0) {
+      var checkboxes = hosts[0].get('checkboxes');
+      return checkboxes.length + 1;
+    }
+    return 1;
+  }.property('controller.hosts.@each.checkboxes')
 });
 
 App.WizardStep6HostView = Em.View.extend({

+ 1 - 3
ambari-web/test/controllers/wizard/step5_test.js

@@ -489,15 +489,13 @@ describe('App.WizardStep5Controller', function () {
       App.router.send.restore();
     });
     it('should go next if not isSubmitDisabled', function () {
-      c.reopen({isSubmitDisabled: false});
+      c.reopen({submitDisabled: false});
       c.submit();
       expect(App.router.send.calledWith('next')).to.equal(true);
     });
     it('shouldn\'t go next if submitDisabled true', function () {
-      sinon.stub(c, 'getIsSubmitDisabled', Em.K);
       c.reopen({submitDisabled: true});
       c.submit();
-      c.getIsSubmitDisabled.restore();
       expect(App.router.send.called).to.equal(false);
     });
   });

+ 279 - 0
ambari-web/test/utils/blueprint_test.js

@@ -0,0 +1,279 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+var blueprintUtils = require('utils/blueprint');
+
+describe('utils/blueprint', function() {
+  var masterBlueprint = {
+    blueprint: {
+      host_groups: [
+        {
+          name: "host-group-1",
+          components: [
+            { name: "ZOOKEEPER_SERVER" },
+            { name: "NAMENODE" },
+            { name: "HBASE_MASTER" }
+          ]
+        },
+        {
+          name: "host-group-2",
+          components: [
+            { name: "SECONDARY_NAMENODE" }
+          ]
+        }
+      ]
+    },
+    blueprint_cluster_binding: {
+      host_groups: [
+        {
+          name: "host-group-1",
+          hosts: [
+            { fqdn: "host1" },
+            { fqdn: "host2" }
+          ]
+        },
+        {
+          name: "host-group-2",
+          hosts: [
+            { fqdn: "host3" }
+          ]
+        }
+      ]
+    }
+  };
+
+  var slaveBlueprint = {
+    blueprint: {
+      host_groups: [
+        {
+          name: "host-group-1",
+          components: [
+            { name: "DATANODE" }
+          ]
+        },
+        {
+          name: "host-group-2",
+          components: [
+            { name: "DATANODE" },
+            { name: "HDFS_CLIENT" },
+            { name: "ZOOKEEPER_CLIENT" }
+          ]
+        }
+      ]
+    },
+    blueprint_cluster_binding: {
+      host_groups: [
+        {
+          name: "host-group-1",
+          hosts: [
+            { fqdn: "host3" }
+          ]
+        },
+        {
+          name: "host-group-2",
+          hosts: [
+            { fqdn: "host4" },
+            { fqdn: "host5" }
+          ]
+        }
+      ]
+    }
+  };
+
+  describe('#getHostsFromBlueprint', function() {
+    it('should extract all hosts from blueprint', function() {
+      expect(blueprintUtils.getHostsFromBlueprint(masterBlueprint)).to.deep.equal(["host1", "host2", "host3"]);
+    });
+  });
+
+  describe('#getHostsFromBlueprintByGroupName', function() {
+    it('should extract hosts from blueprint by given group name', function() {
+      expect(blueprintUtils.getHostsFromBlueprintByGroupName(masterBlueprint, "host-group-1")).to.deep.equal([
+        { fqdn: "host1" },
+        { fqdn: "host2" }
+      ]);
+    });
+
+    it('should return empty array if group with given name doesn\'t exist', function() {
+      expect(blueprintUtils.getHostsFromBlueprintByGroupName(masterBlueprint, "not an existing group")).to.deep.equal([]);
+    });
+  });
+
+  describe('#getComponentsFromBlueprintByGroupName', function() {
+    it('should extract all components from blueprint for given host', function() {
+      expect(blueprintUtils.getComponentsFromBlueprintByGroupName(masterBlueprint, "host-group-1")).to.deep.equal([
+        { name: "ZOOKEEPER_SERVER" },
+        { name: "NAMENODE" },
+        { name: "HBASE_MASTER" }
+      ]);
+    });
+
+    it('should return empty array if group doesn\'t exists', function() {
+      expect(blueprintUtils.getComponentsFromBlueprintByGroupName(masterBlueprint, "not an existing group")).to.deep.equal([]);
+    });
+
+    it('should return empty array if group name isn\'t valid', function() {
+      expect(blueprintUtils.getComponentsFromBlueprintByGroupName(masterBlueprint, undefined)).to.deep.equal([]);
+    });
+  });
+
+  describe('#matchGroups', function() {
+    it('should compose same host group into pairs', function() {
+      expect(blueprintUtils.matchGroups(masterBlueprint, slaveBlueprint)).to.deep.equal([
+        { g1: "host-group-1" },
+        { g1: "host-group-2", g2: "host-group-1" },
+        { g2: "host-group-2" }
+      ]);
+    });
+  });
+
+  describe('#filterByComponents', function() {
+    it('should remove all components except', function() {
+      expect(blueprintUtils.filterByComponents(masterBlueprint, ["NAMENODE"])).to.deep.equal({
+        blueprint: {
+          host_groups: [
+            {
+              name: "host-group-1",
+              components: [
+                { name: "NAMENODE" }
+              ]
+            }
+          ]
+        },
+        blueprint_cluster_binding: {
+          host_groups: [
+            {
+              name: "host-group-1",
+              hosts: [
+                { fqdn: "host1" },
+                { fqdn: "host2" }
+              ]
+            }
+          ]
+        }
+      });
+    });
+  });
+
+  describe('#addComponentsToBlueprint', function() {
+    it('should add components to blueprint', function() {
+      var components = ["FLUME_HANDLER", "HCAT"];
+      expect(blueprintUtils.addComponentsToBlueprint(masterBlueprint, components)).to.deep.equal({
+        blueprint: {
+          host_groups: [
+            {
+              name: "host-group-1",
+              components: [
+                { name: "ZOOKEEPER_SERVER" },
+                { name: "NAMENODE" },
+                { name: "HBASE_MASTER" },
+                { name: "FLUME_HANDLER" },
+                { name: "HCAT" }
+              ]
+            },
+            {
+              name: "host-group-2",
+              components: [
+                { name: "SECONDARY_NAMENODE" },
+                { name: "FLUME_HANDLER" },
+                { name: "HCAT" }
+              ]
+            }
+          ]
+        },
+        blueprint_cluster_binding: {
+          host_groups: [
+            {
+              name: "host-group-1",
+              hosts: [
+                { fqdn: "host1" },
+                { fqdn: "host2" }
+              ]
+            },
+            {
+              name: "host-group-2",
+              hosts: [
+                { fqdn: "host3" }
+              ]
+            }
+          ]
+        }
+      });
+    });
+  });
+
+  describe('#mergeBlueprints', function() {
+    it('should merge components', function() {
+      expect(blueprintUtils.mergeBlueprints(masterBlueprint, slaveBlueprint)).to.deep.equal(
+        {
+          blueprint: {
+            host_groups: [
+              {
+                name: "host-group-1",
+                components: [
+                  { name: "ZOOKEEPER_SERVER" },
+                  { name: "NAMENODE" },
+                  { name: "HBASE_MASTER" }
+                ]
+              },
+              {
+                name: "host-group-2",
+                components: [
+                  { name: "SECONDARY_NAMENODE" },
+                  { name: "DATANODE" }
+                ]
+              },
+              {
+                name: "host-group-3",
+                components: [
+                  { name: "DATANODE" },
+                  { name: "HDFS_CLIENT" },
+                  { name: "ZOOKEEPER_CLIENT" }
+                ]
+              }
+            ]
+          },
+          blueprint_cluster_binding: {
+            host_groups: [
+              {
+                name: "host-group-1",
+                hosts: [
+                  { fqdn: "host1" },
+                  { fqdn: "host2" }
+                ]
+              },
+              {
+                name: "host-group-2",
+                hosts: [
+                  { fqdn: "host3" }
+                ]
+              },
+              {
+                name: "host-group-3",
+                hosts: [
+                  { fqdn: "host4" },
+                  { fqdn: "host5" }
+                ]
+              }
+            ]
+          }
+        }
+      );
+    });
+  });
+});