diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index 14c01706e8..cca182625e 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -853,6 +853,7 @@ class ConductorManager(base_manager.BaseConductorManager): task.driver.power.validate(task) task.driver.deploy.validate(task) utils.validate_instance_info_traits(task.node) + utils.validate_deploy_templates(task) except exception.InvalidParameterValue as e: raise exception.InstanceDeployFailure( _("Failed to validate deploy or power info for node " @@ -2204,6 +2205,7 @@ class ConductorManager(base_manager.BaseConductorManager): iface.validate(task) if iface_name == 'deploy': utils.validate_instance_info_traits(task.node) + utils.validate_deploy_templates(task) result = True except (exception.InvalidParameterValue, exception.UnsupportedDriverExtension) as e: diff --git a/ironic/conductor/utils.py b/ironic/conductor/utils.py index d2187eb8e2..d8eeb4326a 100644 --- a/ironic/conductor/utils.py +++ b/ironic/conductor/utils.py @@ -11,6 +11,8 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + +import collections import time from oslo_config import cfg @@ -27,6 +29,7 @@ from ironic.common import network from ironic.common import states from ironic.conductor import notification_utils as notify_utils from ironic.conductor import task_manager +from ironic.objects import deploy_template from ironic.objects import fields LOG = log.getLogger(__name__) @@ -643,6 +646,18 @@ def _deploy_step_key(step): DEPLOYING_INTERFACE_PRIORITY[step.get('interface')]) +def _sorted_steps(steps, sort_step_key): + """Return a sorted list of steps. + + :param sort_step_key: If set, this is a method (key) used to sort the steps + from highest priority to lowest priority. For steps having the same + priority, they are sorted from highest interface priority to lowest. + :returns: A list of sorted step dictionaries. + """ + # Sort the steps from higher priority to lower priority + return sorted(steps, key=sort_step_key, reverse=True) + + def _get_steps(task, interfaces, get_method, enabled=False, sort_step_key=None): """Get steps for task.node. @@ -672,8 +687,7 @@ def _get_steps(task, interfaces, get_method, enabled=False, if not enabled or x['priority'] > 0] steps.extend(interface_steps) if sort_step_key: - # Sort the steps from higher priority to lower priority - steps = sorted(steps, key=sort_step_key, reverse=True) + steps = _sorted_steps(steps, sort_step_key) return steps @@ -751,6 +765,74 @@ def set_node_cleaning_steps(task): node.save() +def _get_deployment_templates(task): + """Get deployment templates for task.node. + + Return deployment templates where the name of the deployment template + matches one of the node's instance traits (the subset of the node's traits + requested by the user via a flavor or image). + + :param task: A TaskManager object + :returns: a list of DeployTemplate objects. + """ + node = task.node + if not node.instance_info.get('traits'): + return [] + instance_traits = node.instance_info['traits'] + return deploy_template.DeployTemplate.list_by_names(task.context, + instance_traits) + + +def _get_steps_from_deployment_templates(task): + """Get deployment template steps for task.node. + + Deployment template steps are those steps defined in deployment templates + where the name of the deployment template matches one of the node's + instance traits (the subset of the node's traits requested by the user via + a flavor or image). There may be many such matching templates, each with a + list of steps to execute. + + :param task: A TaskManager object + :returns: A list of deploy step dictionaries + """ + templates = _get_deployment_templates(task) + steps = [] + step_fields = ('interface', 'step', 'args', 'priority') + for template in templates: + steps.extend([{key: step[key] for key in step_fields} + for step in template.steps]) + return steps + + +def _get_all_deployment_steps(task): + """Get deployment steps for task.node. + + Deployment steps from matching deployment templates are combined with those + from driver interfaces and all enabled steps returned in priority order. + + :param task: A TaskManager object + :raises: InstanceDeployFailure if there was a problem getting the + deploy steps. + :returns: A list of deploy step dictionaries + """ + # Gather deploy steps from deploy templates. + user_steps = _get_steps_from_deployment_templates(task) + + # Gather enabled deploy steps from drivers. + driver_steps = _get_deployment_steps(task, enabled=True, sort=False) + + # Remove driver steps that have been disabled or overridden by user steps. + user_step_keys = {(s['interface'], s['step']) for s in user_steps} + steps = [s for s in driver_steps + if (s['interface'], s['step']) not in user_step_keys] + + # Add enabled user steps. + enabled_user_steps = [s for s in user_steps if s['priority'] > 0] + steps.extend(enabled_user_steps) + + return _sorted_steps(steps, _deploy_step_key) + + def set_node_deployment_steps(task): """Set up the node with deployment step information for deploying. @@ -761,14 +843,212 @@ def set_node_deployment_steps(task): """ node = task.node driver_internal_info = node.driver_internal_info - driver_internal_info['deploy_steps'] = _get_deployment_steps( - task, enabled=True) + driver_internal_info['deploy_steps'] = _get_all_deployment_steps(task) node.deploy_step = {} driver_internal_info['deploy_step_index'] = None node.driver_internal_info = driver_internal_info node.save() +def _validate_deploy_steps_unique(user_steps): + """Validate that deploy steps from deploy templates are unique. + + :param user_steps: a list of user steps. A user step is a dictionary + with required keys 'interface', 'step', 'args', and 'priority':: + + { 'interface': , + 'step': , + 'args': {: , ..., : } } + + For example:: + + { 'interface': deploy', + 'step': 'upgrade_firmware', + 'args': {'force': True} } + :return: a list of validation error strings for the steps. + """ + # Check for duplicate steps. Each interface/step combination can be + # specified at most once. + errors = [] + counter = collections.Counter((step['interface'], step['step']) + for step in user_steps) + for (interface, step), count in counter.items(): + if count > 1: + err = (_('duplicate deploy steps for %(interface)s.%(step)s. ' + 'Deploy steps from all deploy templates matching a ' + 'node\'s instance traits cannot have the same interface ' + 'and step') % + {'interface': interface, 'step': step}) + errors.append(err) + return errors + + +def _validate_user_step(task, user_step, driver_step, step_type): + """Validate a user-specified step. + + :param task: A TaskManager object + :param user_step: a user step dictionary with required keys 'interface' + and 'step', and optional keys 'args' and 'priority':: + + { 'interface': , + 'step': , + 'args': {: , ..., : }, + 'priority': } + + For example:: + + { 'interface': deploy', + 'step': 'upgrade_firmware', + 'args': {'force': True} } + :param driver_step: a driver step dictionary:: + { 'interface': , + 'step': , + 'priority': + 'abortable': Optional for clean steps, absent for deploy steps. + . + 'argsinfo': Optional. A dictionary of + {:} entries. + is a dictionary with + { 'description': , + 'required': } + } + For example:: + + { 'interface': deploy', + 'step': 'upgrade_firmware', + 'priority': 10, + 'abortable': True, + 'argsinfo': { + 'force': { 'description': 'Whether to force the upgrade', + 'required': False } } } + :param step_type: either 'clean' or 'deploy'. + :return: a list of validation error strings for the step. + """ + errors = [] + # Check that the user-specified arguments are valid + argsinfo = driver_step.get('argsinfo') or {} + user_args = user_step.get('args') or {} + invalid = set(user_args) - set(argsinfo) + if invalid: + error = (_('%(type)s step %(step)s has these invalid arguments: ' + '%(invalid)s') % + {'type': step_type, 'step': user_step, + 'invalid': ', '.join(invalid)}) + errors.append(error) + + if step_type == 'clean' or user_step['priority'] > 0: + # Check that all required arguments were specified by the user + missing = [] + for (arg_name, arg_info) in argsinfo.items(): + if arg_info.get('required', False) and arg_name not in user_args: + msg = arg_name + if arg_info.get('description'): + msg += ' (%(desc)s)' % {'desc': arg_info['description']} + missing.append(msg) + if missing: + error = (_('%(type)s step %(step)s is missing these required ' + 'keyword arguments: %(miss)s') % + {'type': step_type, 'step': user_step, + 'miss': ', '.join(missing)}) + errors.append(error) + + if step_type == 'clean': + # Copy fields that should not be provided by a user + user_step['abortable'] = driver_step.get('abortable', False) + user_step['priority'] = driver_step.get('priority', 0) + elif user_step['priority'] > 0: + # 'core' deploy steps can only be disabled. + is_core = (driver_step['interface'] == 'deploy' and + driver_step['step'] == 'deploy') + if is_core: + error = (_('deploy step %(step)s is a core step and ' + 'cannot be overridden by user steps. It may be ' + 'disabled by setting the priority to 0') + % {'step': user_step}) + errors.append(error) + + return errors + + +def _validate_user_steps(task, user_steps, driver_steps, step_type): + """Validate the user-specified steps. + + :param task: A TaskManager object + :param user_steps: a list of user steps. A user step is a dictionary + with required keys 'interface' and 'step', and optional keys 'args' + and 'priority':: + + { 'interface': , + 'step': , + 'args': {: , ..., : }, + 'priority': } + + For example:: + + { 'interface': deploy', + 'step': 'upgrade_firmware', + 'args': {'force': True} } + :param driver_steps: a list of driver steps:: + { 'interface': , + 'step': , + 'priority': + 'abortable': Optional for clean steps, absent for deploy steps. + . + 'argsinfo': Optional. A dictionary of + {:} entries. + is a dictionary with + { 'description': , + 'required': } + } + For example:: + + { 'interface': deploy', + 'step': 'upgrade_firmware', + 'priority': 10, + 'abortable': True, + 'argsinfo': { + 'force': { 'description': 'Whether to force the upgrade', + 'required': False } } } + :param step_type: either 'clean' or 'deploy'. + :raises: InvalidParameterValue if validation of steps fails. + :raises: NodeCleaningFailure or InstanceDeployFailure if + there was a problem getting the steps from the driver. + :return: validated steps updated with information from the driver + """ + + def step_id(step): + return '.'.join([step['step'], step['interface']]) + + errors = [] + + # Convert driver steps to a dict. + driver_steps = {step_id(s): s for s in driver_steps} + + for user_step in user_steps: + # Check if this user_specified step isn't supported by the driver + try: + driver_step = driver_steps[step_id(user_step)] + except KeyError: + error = (_('node does not support this %(type)s step: %(step)s') + % {'type': step_type, 'step': user_step}) + errors.append(error) + continue + + step_errors = _validate_user_step(task, user_step, driver_step, + step_type) + errors.extend(step_errors) + + if step_type == 'deploy': + # Deploy steps should be unique across all combined templates. + dup_errors = _validate_deploy_steps_unique(user_steps) + errors.extend(dup_errors) + + if errors: + raise exception.InvalidParameterValue('; '.join(errors)) + + return user_steps[:] + + def _validate_user_clean_steps(task, user_steps): """Validate the user-specified clean steps. @@ -782,7 +1062,7 @@ def _validate_user_clean_steps(task, user_steps): For example:: - { 'interface': deploy', + { 'interface': 'deploy', 'step': 'upgrade_firmware', 'args': {'force': True} } :raises: InvalidParameterValue if validation of clean steps fails. @@ -790,69 +1070,35 @@ def _validate_user_clean_steps(task, user_steps): clean steps from the driver. :return: validated clean steps update with information from the driver """ + driver_steps = _get_cleaning_steps(task, enabled=False, sort=False) + return _validate_user_steps(task, user_steps, driver_steps, 'clean') - def step_id(step): - return '.'.join([step['step'], step['interface']]) - errors = [] +def _validate_user_deploy_steps(task, user_steps): + """Validate the user-specified deploy steps. - # The clean steps from the driver. A clean step dictionary is of the form: - # { 'interface': , - # 'step': , - # 'priority': - # 'abortable': Optional. . - # 'argsinfo': Optional. A dictionary of {:} - # entries. is a dictionary with - # { 'description': , - # 'required': } - # } - driver_steps = {} - for s in _get_cleaning_steps(task, enabled=False, sort=False): - driver_steps[step_id(s)] = s + :param task: A TaskManager object + :param user_steps: a list of deploy steps. A deploy step is a dictionary + with required keys 'interface', 'step', 'args', and 'priority':: - result = [] - for user_step in user_steps: - # Check if this user_specified clean step isn't supported by the driver - try: - driver_step = driver_steps[step_id(user_step)] - except KeyError: - error = (_('node does not support this clean step: %(step)s') - % {'step': user_step}) - errors.append(error) - continue + { 'interface': , + 'step': , + 'args': {: , ..., : }, + 'priority': } - # Check that the user-specified arguments are valid - argsinfo = driver_step.get('argsinfo') or {} - user_args = user_step.get('args') or {} - invalid = set(user_args) - set(argsinfo) - if invalid: - error = _('clean step %(step)s has these invalid arguments: ' - '%(invalid)s') % {'step': user_step, - 'invalid': ', '.join(invalid)} - errors.append(error) + For example:: - # Check that all required arguments were specified by the user - missing = [] - for (arg_name, arg_info) in argsinfo.items(): - if arg_info.get('required', False) and arg_name not in user_args: - msg = arg_name - if arg_info.get('description'): - msg += ' (%(desc)s)' % {'desc': arg_info['description']} - missing.append(msg) - if missing: - error = _('clean step %(step)s is missing these required keyword ' - 'arguments: %(miss)s') % {'step': user_step, - 'miss': ', '.join(missing)} - errors.append(error) - - # Copy fields that should not be provided by a user - user_step['abortable'] = driver_step.get('abortable', False) - user_step['priority'] = driver_step.get('priority', 0) - result.append(user_step) - - if errors: - raise exception.InvalidParameterValue('; '.join(errors)) - return result + { 'interface': 'bios', + 'step': 'apply_configuration', + 'args': { 'settings': [ { 'foo': 'bar' } ] }, + 'priority': 150 } + :raises: InvalidParameterValue if validation of deploy steps fails. + :raises: InstanceDeployFailure if there was a problem getting the deploy + steps from the driver. + :return: validated deploy steps update with information from the driver + """ + driver_steps = _get_deployment_steps(task, enabled=False, sort=False) + return _validate_user_steps(task, user_steps, driver_steps, 'deploy') @task_manager.require_exclusive_lock @@ -1056,3 +1302,17 @@ def restore_power_state_if_needed(task, power_state_to_restore): # enough time to apply network changes. time.sleep(CONF.agent.neutron_agent_poll_interval * 2) node_power_action(task, power_state_to_restore) + + +def validate_deploy_templates(task): + """Validate the deploy templates for a node. + + :param task: A TaskManager object + :raises: InvalidParameterValue if the instance has traits that map to + deploy steps that are unsupported by the node's driver interfaces. + :raises: InstanceDeployFailure if there was a problem getting the deploy + steps from the driver. + """ + # Gather deploy steps from matching deploy templates, validate them. + user_steps = _get_steps_from_deployment_templates(task) + _validate_user_deploy_steps(task, user_steps) diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index 836678f8b6..e63d1cbc64 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -1325,6 +1325,11 @@ class ServiceDoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, mock_iwdi): self._test_do_node_deploy_validate_fail(mock_validate, mock_iwdi) + @mock.patch.object(conductor_utils, 'validate_deploy_templates') + def test_do_node_deploy_validate_template_fail(self, mock_validate, + mock_iwdi): + self._test_do_node_deploy_validate_fail(mock_validate, mock_iwdi) + def test_do_node_deploy_partial_ok(self, mock_iwdi): mock_iwdi.return_value = False self._start_service() @@ -4795,6 +4800,23 @@ class MiscTestCase(mgr_utils.ServiceSetUpMixin, mgr_utils.CommonMixIn, self.assertEqual(reason, ret['deploy']['reason']) mock_iwdi.assert_called_once_with(self.context, node.instance_info) + @mock.patch.object(images, 'is_whole_disk_image') + def test_validate_driver_interfaces_validation_fail_deploy_templates( + self, mock_iwdi): + mock_iwdi.return_value = False + node = obj_utils.create_test_node(self.context, driver='fake-hardware', + network_interface='noop') + with mock.patch( + 'ironic.conductor.utils.validate_deploy_templates' + ) as mock_validate: + reason = 'fake reason' + mock_validate.side_effect = exception.InvalidParameterValue(reason) + ret = self.service.validate_driver_interfaces(self.context, + node.uuid) + self.assertFalse(ret['deploy']['result']) + self.assertEqual(reason, ret['deploy']['reason']) + mock_iwdi.assert_called_once_with(self.context, node.instance_info) + @mock.patch.object(manager.ConductorManager, '_fail_if_in_state', autospec=True) @mock.patch.object(manager.ConductorManager, '_mapped_to_this_conductor') diff --git a/ironic/tests/unit/conductor/test_utils.py b/ironic/tests/unit/conductor/test_utils.py index 8c8a69bb6b..d49f73fee5 100644 --- a/ironic/tests/unit/conductor/test_utils.py +++ b/ironic/tests/unit/conductor/test_utils.py @@ -980,9 +980,16 @@ class NodeDeployStepsTestCase(db_base.DbTestCase): 'step': 'deploy_end', 'priority': 20, 'interface': 'deploy'} self.power_disable = { 'step': 'power_disable', 'priority': 0, 'interface': 'power'} + self.deploy_core = { + 'step': 'deploy', 'priority': 100, 'interface': 'deploy'} # enabled steps self.deploy_steps = [self.deploy_start, self.power_one, self.deploy_middle, self.deploy_end] + # Deploy step with argsinfo. + self.deploy_raid = { + 'step': 'build_raid', 'priority': 0, 'interface': 'deploy', + 'argsinfo': {'arg1': {'description': 'desc1', 'required': True}, + 'arg2': {'description': 'desc2'}}} self.node = obj_utils.create_test_node( self.context, driver='fake-hardware') @@ -1058,7 +1065,148 @@ class NodeDeployStepsTestCase(db_base.DbTestCase): mock_power_steps.assert_called_once_with(mock.ANY, task) mock_deploy_steps.assert_called_once_with(mock.ANY, task) - @mock.patch.object(conductor_utils, '_get_deployment_steps', + @mock.patch.object(objects.DeployTemplate, 'list_by_names') + def test__get_deployment_templates_no_traits(self, mock_list): + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + templates = conductor_utils._get_deployment_templates(task) + self.assertEqual([], templates) + self.assertFalse(mock_list.called) + + @mock.patch.object(objects.DeployTemplate, 'list_by_names') + def test__get_deployment_templates(self, mock_list): + traits = ['CUSTOM_DT1', 'CUSTOM_DT2'] + node = obj_utils.create_test_node( + self.context, uuid=uuidutils.generate_uuid(), + driver='fake-hardware', instance_info={'traits': traits}) + template1 = obj_utils.get_test_deploy_template(self.context) + template2 = obj_utils.get_test_deploy_template( + self.context, name='CUSTOM_DT2', uuid=uuidutils.generate_uuid(), + steps=[{'interface': 'bios', 'step': 'apply_configuration', + 'args': {}, 'priority': 1}]) + mock_list.return_value = [template1, template2] + expected = [template1, template2] + with task_manager.acquire( + self.context, node.uuid, shared=False) as task: + templates = conductor_utils._get_deployment_templates(task) + self.assertEqual(expected, templates) + mock_list.assert_called_once_with(task.context, traits) + + @mock.patch.object(conductor_utils, '_get_deployment_templates', + autospec=True) + def test__get_steps_from_deployment_templates(self, mock_templates): + template1 = obj_utils.get_test_deploy_template(self.context) + template2 = obj_utils.get_test_deploy_template( + self.context, name='CUSTOM_DT2', uuid=uuidutils.generate_uuid(), + steps=[{'interface': 'bios', 'step': 'apply_configuration', + 'args': {}, 'priority': 1}]) + mock_templates.return_value = [template1, template2] + step1 = template1.steps[0] + step2 = template2.steps[0] + expected = [ + { + 'interface': step1['interface'], + 'step': step1['step'], + 'args': step1['args'], + 'priority': step1['priority'], + }, + { + 'interface': step2['interface'], + 'step': step2['step'], + 'args': step2['args'], + 'priority': step2['priority'], + } + ] + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + steps = conductor_utils._get_steps_from_deployment_templates(task) + self.assertEqual(expected, steps) + mock_templates.assert_called_once_with(task) + + @mock.patch.object(conductor_utils, '_get_steps_from_deployment_templates', + autospec=True) + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def _test__get_all_deployment_steps(self, user_steps, driver_steps, + expected_steps, mock_gds, mock_gsfdt): + mock_gsfdt.return_value = user_steps + mock_gds.return_value = driver_steps + + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + steps = conductor_utils._get_all_deployment_steps(task) + self.assertEqual(expected_steps, steps) + mock_gsfdt.assert_called_once_with(task) + mock_gds.assert_called_once_with(task, enabled=True, sort=False) + + def test__get_all_deployment_steps_no_steps(self): + # Nothing in -> nothing out. + user_steps = [] + driver_steps = [] + expected_steps = [] + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + def test__get_all_deployment_steps_no_user_steps(self): + # Only driver steps in -> only driver steps out. + user_steps = [] + driver_steps = self.deploy_steps + expected_steps = self.deploy_steps + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + def test__get_all_deployment_steps_no_driver_steps(self): + # Only user steps in -> only user steps out. + user_steps = self.deploy_steps + driver_steps = [] + expected_steps = self.deploy_steps + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + def test__get_all_deployment_steps_user_and_driver_steps(self): + # Driver and user steps in -> driver and user steps out. + user_steps = self.deploy_steps[:2] + driver_steps = self.deploy_steps[2:] + expected_steps = self.deploy_steps + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + def test__get_all_deployment_steps_disable_core_steps(self): + # User steps can disable core driver steps. + user_steps = [self.deploy_core.copy()] + user_steps[0].update({'priority': 0}) + driver_steps = [self.deploy_core] + expected_steps = [] + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + def test__get_all_deployment_steps_override_driver_steps(self): + # User steps override non-core driver steps. + user_steps = [step.copy() for step in self.deploy_steps[:2]] + user_steps[0].update({'priority': 200}) + user_steps[1].update({'priority': 100}) + driver_steps = self.deploy_steps + expected_steps = user_steps + self.deploy_steps[2:] + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + def test__get_all_deployment_steps_duplicate_user_steps(self): + # Duplicate user steps override non-core driver steps. + + # NOTE(mgoddard): This case is currently prevented by the API and + # conductor - the interface/step must be unique across all enabled + # steps. This test ensures that we can support this case, in case we + # choose to allow it in future. + user_steps = [self.deploy_start.copy(), self.deploy_start.copy()] + user_steps[0].update({'priority': 200}) + user_steps[1].update({'priority': 100}) + driver_steps = self.deploy_steps + # Each user invocation of the deploy_start step should be included, but + # not the default deploy_start from the driver. + expected_steps = user_steps + self.deploy_steps[1:] + self._test__get_all_deployment_steps(user_steps, driver_steps, + expected_steps) + + @mock.patch.object(conductor_utils, '_get_all_deployment_steps', autospec=True) def test_set_node_deployment_steps(self, mock_steps): mock_steps.return_value = self.deploy_steps @@ -1072,7 +1220,147 @@ class NodeDeployStepsTestCase(db_base.DbTestCase): self.assertEqual({}, self.node.deploy_step) self.assertIsNone( self.node.driver_internal_info['deploy_step_index']) - mock_steps.assert_called_once_with(task, enabled=True) + mock_steps.assert_called_once_with(task) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps(self, mock_steps): + mock_steps.return_value = self.deploy_steps + + user_steps = [{'step': 'deploy_start', 'interface': 'deploy', + 'priority': 100}, + {'step': 'power_one', 'interface': 'power', + 'priority': 200}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + result = conductor_utils._validate_user_deploy_steps(task, + user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + self.assertEqual(user_steps, result) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_no_steps(self, mock_steps): + mock_steps.return_value = self.deploy_steps + + with task_manager.acquire(self.context, self.node.uuid) as task: + conductor_utils._validate_user_deploy_steps(task, []) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_get_steps_exception(self, mock_steps): + mock_steps.side_effect = exception.InstanceDeployFailure('bad') + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaises(exception.InstanceDeployFailure, + conductor_utils._validate_user_deploy_steps, + task, []) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_not_supported(self, mock_steps): + mock_steps.return_value = self.deploy_steps + user_steps = [{'step': 'power_one', 'interface': 'power', + 'priority': 200}, + {'step': 'bad_step', 'interface': 'deploy', + 'priority': 100}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaisesRegex(exception.InvalidParameterValue, + "does not support.*bad_step", + conductor_utils._validate_user_deploy_steps, + task, user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_invalid_arg(self, mock_steps): + mock_steps.return_value = self.deploy_steps + user_steps = [{'step': 'power_one', 'interface': 'power', + 'args': {'arg1': 'val1', 'arg2': 'val2'}, + 'priority': 200}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaisesRegex(exception.InvalidParameterValue, + "power_one.*invalid.*arg1", + conductor_utils._validate_user_deploy_steps, + task, user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_missing_required_arg(self, + mock_steps): + mock_steps.return_value = [self.power_one, self.deploy_raid] + user_steps = [{'step': 'power_one', 'interface': 'power', + 'priority': 200}, + {'step': 'build_raid', 'interface': 'deploy', + 'priority': 100}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaisesRegex(exception.InvalidParameterValue, + "build_raid.*missing.*arg1", + conductor_utils._validate_user_deploy_steps, + task, user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_disable_non_core(self, mock_steps): + # Required arguments don't apply to disabled steps. + mock_steps.return_value = [self.power_one, self.deploy_raid] + user_steps = [{'step': 'power_one', 'interface': 'power', + 'priority': 200}, + {'step': 'build_raid', 'interface': 'deploy', + 'priority': 0}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + result = conductor_utils._validate_user_deploy_steps(task, + user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + self.assertEqual(user_steps, result) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_disable_core(self, mock_steps): + mock_steps.return_value = [self.power_one, self.deploy_core] + user_steps = [{'step': 'power_one', 'interface': 'power', + 'priority': 200}, + {'step': 'deploy', 'interface': 'deploy', 'priority': 0}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + result = conductor_utils._validate_user_deploy_steps(task, + user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + self.assertEqual(user_steps, result) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_override_core(self, mock_steps): + mock_steps.return_value = [self.power_one, self.deploy_core] + user_steps = [{'step': 'power_one', 'interface': 'power', + 'priority': 200}, + {'step': 'deploy', 'interface': 'deploy', + 'priority': 200}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaisesRegex(exception.InvalidParameterValue, + "deploy.*is a core step", + conductor_utils._validate_user_deploy_steps, + task, user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) + + @mock.patch.object(conductor_utils, '_get_deployment_steps', autospec=True) + def test__validate_user_deploy_steps_duplicates(self, mock_steps): + mock_steps.return_value = [self.power_one, self.deploy_core] + user_steps = [{'step': 'power_one', 'interface': 'power', + 'priority': 200}, + {'step': 'power_one', 'interface': 'power', + 'priority': 100}] + + with task_manager.acquire(self.context, self.node.uuid) as task: + self.assertRaisesRegex(exception.InvalidParameterValue, + "duplicate deploy steps for " + "power.power_one", + conductor_utils._validate_user_deploy_steps, + task, user_steps) + mock_steps.assert_called_once_with(task, enabled=False, sort=False) class NodeCleaningStepsTestCase(db_base.DbTestCase): @@ -2141,14 +2429,14 @@ class ValidateInstanceInfoTraitsTestCase(tests_base.TestCase): self.node.instance_info['traits'] = [] conductor_utils.validate_instance_info_traits(self.node) - def test_parse_instance_info_traits_invalid_type(self): + def test_validate_instance_info_traits_invalid_type(self): self.node.instance_info['traits'] = 'not-a-list' self.assertRaisesRegex(exception.InvalidParameterValue, 'Error parsing traits from Node', conductor_utils.validate_instance_info_traits, self.node) - def test_parse_instance_info_traits_invalid_trait_type(self): + def test_validate_instance_info_traits_invalid_trait_type(self): self.node.instance_info['traits'] = ['trait1', {}] self.assertRaisesRegex(exception.InvalidParameterValue, 'Error parsing traits from Node', @@ -2165,3 +2453,39 @@ class ValidateInstanceInfoTraitsTestCase(tests_base.TestCase): 'Cannot specify instance traits that are not', conductor_utils.validate_instance_info_traits, self.node) + + +@mock.patch.object(conductor_utils, '_get_steps_from_deployment_templates', + autospec=True) +@mock.patch.object(conductor_utils, '_validate_user_deploy_steps', + autospec=True) +class ValidateDeployTemplatesTestCase(db_base.DbTestCase): + + def setUp(self): + super(ValidateDeployTemplatesTestCase, self).setUp() + self.node = obj_utils.create_test_node(self.context, + driver='fake-hardware') + + def test_validate_deploy_templates(self, mock_validate, mock_get): + steps = [db_utils.get_test_deploy_template_step()] + mock_get.return_value = steps + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + conductor_utils.validate_deploy_templates(task) + mock_validate.assert_called_once_with(task, steps) + + def test_validate_deploy_templates_invalid_parameter_value( + self, mock_validate, mock_get): + mock_validate.side_effect = exception.InvalidParameterValue('fake') + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + self.assertRaises(exception.InvalidParameterValue, + conductor_utils.validate_deploy_templates, task) + + def test_validate_deploy_templates_instance_deploy_failure( + self, mock_validate, mock_get): + mock_validate.side_effect = exception.InstanceDeployFailure('foo') + with task_manager.acquire( + self.context, self.node.uuid, shared=False) as task: + self.assertRaises(exception.InstanceDeployFailure, + conductor_utils.validate_deploy_templates, task)