From 1b4ce47921b6a09fbf2894b021217a87d82fe7aa Mon Sep 17 00:00:00 2001 From: Mark Goddard Date: Thu, 12 Dec 2019 17:13:08 +0000 Subject: [PATCH] Add an ability to run in-band deploy steps Mostly adaptation of cleaning methods. Co-Authored-By: Dmitry Tantsur Change-Id: Ife0502391bbece46d619a20a825dfdb191d5c2b4 Story: 2006963 Task: 37791 --- ironic_python_agent/errors.py | 31 ++- ironic_python_agent/extensions/base.py | 8 +- ironic_python_agent/extensions/clean.py | 111 +------- ironic_python_agent/extensions/deploy.py | 95 +++++++ ironic_python_agent/hardware.py | 163 ++++++++++++ .../tests/unit/extensions/test_clean.py | 37 +-- .../tests/unit/extensions/test_deploy.py | 241 ++++++++++++++++++ .../tests/unit/test_hardware.py | 21 ++ setup.cfg | 1 + 9 files changed, 559 insertions(+), 149 deletions(-) create mode 100644 ironic_python_agent/extensions/deploy.py create mode 100644 ironic_python_agent/tests/unit/extensions/test_deploy.py diff --git a/ironic_python_agent/errors.py b/ironic_python_agent/errors.py index d263af5f4..99bbe2b81 100644 --- a/ironic_python_agent/errors.py +++ b/ironic_python_agent/errors.py @@ -263,24 +263,26 @@ class IncompatibleHardwareMethodError(RESTError): super(IncompatibleHardwareMethodError, self).__init__(details) -class CleanVersionMismatch(RESTError): +class VersionMismatch(RESTError): """Error raised when Ironic and the Agent have different versions. - If the agent version has changed since get_clean_steps was called by - the Ironic conductor, it indicates the agent has been updated (either - on purpose, or a new agent was deployed and the node was rebooted). - Since we cannot know if the upgraded IPA will work with cleaning as it - stands (steps could have different priorities, either in IPA or in - other Ironic interfaces), we should restart cleaning from the start. + If the agent version has changed since get_clean_steps or get_deploy_steps + was called by the Ironic conductor, it indicates the agent has been updated + (either on purpose, or a new agent was deployed and the node was rebooted). + Since we cannot know if the upgraded IPA will work with cleaning/deploy as + it stands (steps could have different priorities, either in IPA or in + other Ironic interfaces), we should restart the process from the start. """ - message = 'Clean version mismatch, reload agent with correct version' + message = ( + 'Hardware managers version mismatch, reload agent with correct version' + ) def __init__(self, agent_version, node_version): self.status_code = 409 - details = ('Agent clean version: {}, node clean version: {}' + details = ('Current versions: {}, versions used by ironic: {}' .format(agent_version, node_version)) - super(CleanVersionMismatch, self).__init__(details) + super(VersionMismatch, self).__init__(details) class CleaningError(RESTError): @@ -292,6 +294,15 @@ class CleaningError(RESTError): super(CleaningError, self).__init__(details) +class DeploymentError(RESTError): + """Error raised when a deploy step fails.""" + + message = 'Deploy step failed' + + def __init__(self, details=None): + super(DeploymentError, self).__init__(details) + + class ISCSIError(RESTError): """Error raised when an image cannot be written to a device.""" diff --git a/ironic_python_agent/extensions/base.py b/ironic_python_agent/extensions/base.py index b15c844b0..a4314098b 100644 --- a/ironic_python_agent/extensions/base.py +++ b/ironic_python_agent/extensions/base.py @@ -33,7 +33,9 @@ class AgentCommandStatus(object): RUNNING = u'RUNNING' SUCCEEDED = u'SUCCEEDED' FAILED = u'FAILED' - CLEAN_VERSION_MISMATCH = u'CLEAN_VERSION_MISMATCH' + # TODO(dtantsur): keeping the same text for backward compatibility, change + # to just VERSION_MISMATCH one release after ironic is updated. + VERSION_MISMATCH = u'CLEAN_VERSION_MISMATCH' class BaseCommandResult(encoding.SerializableComparable): @@ -167,10 +169,10 @@ class AsyncCommandResult(BaseCommandResult): with self.command_state_lock: self.command_result = result self.command_status = AgentCommandStatus.SUCCEEDED - except errors.CleanVersionMismatch as e: + except errors.VersionMismatch as e: with self.command_state_lock: self.command_error = e - self.command_status = AgentCommandStatus.CLEAN_VERSION_MISMATCH + self.command_status = AgentCommandStatus.VERSION_MISMATCH self.command_result = None LOG.error('Clean version mismatch for command %s', self.command_name) diff --git a/ironic_python_agent/extensions/clean.py b/ironic_python_agent/extensions/clean.py index 3bdf5a1e5..5eabded0d 100644 --- a/ironic_python_agent/extensions/clean.py +++ b/ironic_python_agent/extensions/clean.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import collections - from oslo_log import log from ironic_python_agent import errors @@ -42,12 +40,12 @@ class CleanExtension(base.BaseAgentExtension): node, ports) LOG.debug('Clean steps before deduplication: %s', candidate_steps) - clean_steps = _deduplicate_steps(candidate_steps) + clean_steps = hardware.deduplicate_steps(candidate_steps) LOG.debug('Returning clean steps: %s', clean_steps) return { 'clean_steps': clean_steps, - 'hardware_manager_version': _get_current_clean_version() + 'hardware_manager_version': hardware.get_current_versions(), } @base.async_command('execute_clean_step') @@ -59,7 +57,7 @@ class CleanExtension(base.BaseAgentExtension): :param node: A dict representation of a node :param ports: A dict representation of ports attached to node :param clean_version: The clean version as returned by - _get_current_clean_version() at the beginning + hardware.get_current_versions() at the beginning of cleaning/zapping :returns: a CommandResult object with command_result set to whatever the step returns. @@ -67,7 +65,7 @@ class CleanExtension(base.BaseAgentExtension): # Ensure the agent is still the same version, or raise an exception LOG.debug('Executing clean step %s', step) hardware.cache_node(node) - _check_clean_version(clean_version) + hardware.check_versions(clean_version) if 'step' not in step: msg = 'Malformed clean_step, no "step" key: %s' % step @@ -95,104 +93,3 @@ class CleanExtension(base.BaseAgentExtension): 'clean_result': result, 'clean_step': step } - - -def _deduplicate_steps(candidate_steps): - """Remove duplicated clean steps - - Deduplicates clean steps returned from HardwareManagers to prevent - running a given step more than once. Other than individual step - priority, it doesn't actually impact the cleaning run which specific - steps are kept and what HardwareManager they are associated with. - However, in order to make testing easier, this method returns - deterministic results. - - Uses the following filtering logic to decide which step "wins": - - Keep the step that belongs to HardwareManager with highest - HardwareSupport (larger int) value. - - If equal support level, keep the step with the higher defined priority - (larger int). - - If equal support level and priority, keep the step associated with the - HardwareManager whose name comes earlier in the alphabet. - - :param candidate_steps: A dict containing all possible clean steps from - all managers, key=manager, value=list of clean steps - :returns: A deduplicated dictionary of {hardware_manager: - [clean-steps]} - """ - support = hardware.dispatch_to_all_managers( - 'evaluate_hardware_support') - - steps = collections.defaultdict(list) - deduped_steps = collections.defaultdict(list) - - for manager, manager_steps in candidate_steps.items(): - # We cannot deduplicate steps with unknown hardware support - if manager not in support: - LOG.warning('Unknown hardware support for %(manager)s, ' - 'dropping clean steps: %(steps)s', - {'manager': manager, 'steps': manager_steps}) - continue - - for step in manager_steps: - # build a new dict of steps that's easier to filter - step['hwm'] = {'name': manager, - 'support': support[manager]} - steps[step['step']].append(step) - - for step_name, step_list in steps.items(): - # determine the max support level among candidate steps - max_support = max([x['hwm']['support'] for x in step_list]) - # filter out any steps that are not at the max support for this step - max_support_steps = [x for x in step_list - if x['hwm']['support'] == max_support] - - # determine the max priority among remaining steps - max_priority = max([x['priority'] for x in max_support_steps]) - # filter out any steps that are not at the max priority for this step - max_priority_steps = [x for x in max_support_steps - if x['priority'] == max_priority] - - # if there are still multiple steps, sort by hwm name and take - # the first result - winning_step = sorted(max_priority_steps, - key=lambda x: x['hwm']['name'])[0] - # Remove extra metadata we added to the step for filtering - manager = winning_step.pop('hwm')['name'] - # Add winning step to deduped_steps - deduped_steps[manager].append(winning_step) - - return deduped_steps - - -def _check_clean_version(clean_version=None): - """Ensure the clean version hasn't changed. - - :param clean_version: Hardware manager versions used during this - cleaning cycle. - :raises: errors.CleanVersionMismatch if any hardware manager version on - the currently running agent doesn't match the one stored in - clean_version. - :returns: None - """ - # If the version is None, assume this is the first run - if clean_version is None: - return - agent_version = _get_current_clean_version() - if clean_version != agent_version: - LOG.warning('Mismatched clean versions. Agent version: %(agent)s, ' - 'node version: %(node)s', {'agent': agent_version, - 'node': clean_version}) - raise errors.CleanVersionMismatch(agent_version=agent_version, - node_version=clean_version) - - -def _get_current_clean_version(): - """Fetches versions from all hardware managers. - - :returns: Dict in the format {name: version} containing one entry for - every hardware manager. - """ - return {version.get('name'): version.get('version') - for version in hardware.dispatch_to_all_managers( - 'get_version').values()} diff --git a/ironic_python_agent/extensions/deploy.py b/ironic_python_agent/extensions/deploy.py new file mode 100644 index 000000000..2fef7b1dc --- /dev/null +++ b/ironic_python_agent/extensions/deploy.py @@ -0,0 +1,95 @@ +# Licensed 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. + +from oslo_log import log + +from ironic_python_agent import errors +from ironic_python_agent.extensions import base +from ironic_python_agent import hardware + +LOG = log.getLogger() + + +class DeployExtension(base.BaseAgentExtension): + @base.sync_command('get_deploy_steps') + def get_deploy_steps(self, node, ports): + """Get the list of deploy steps supported for the node and ports + + :param node: A dict representation of a node + :param ports: A dict representation of ports attached to node + + :returns: A list of deploy steps with keys step, priority, and + reboot_requested + """ + LOG.debug('Getting deploy steps, called with node: %(node)s, ' + 'ports: %(ports)s', {'node': node, 'ports': ports}) + hardware.cache_node(node) + # Results should be a dict, not a list + candidate_steps = hardware.dispatch_to_all_managers('get_deploy_steps', + node, ports) + + LOG.debug('Deploy steps before deduplication: %s', candidate_steps) + deploy_steps = hardware.deduplicate_steps(candidate_steps) + LOG.debug('Returning deploy steps: %s', deploy_steps) + + return { + 'deploy_steps': deploy_steps, + 'hardware_manager_version': hardware.get_current_versions(), + } + + @base.async_command('execute_deploy_step') + def execute_deploy_step(self, step, node, ports, deploy_version=None, + **kwargs): + """Execute a deploy step. + + :param step: A deploy step with 'step', 'priority' and 'interface' keys + :param node: A dict representation of a node + :param ports: A dict representation of ports attached to node + :param deploy_version: The deploy version as returned by + hardware.get_current_versions() at the beginning + of deploying. + :param kwargs: The remaining arguments are passed to the step. + :returns: a CommandResult object with command_result set to whatever + the step returns. + """ + # Ensure the agent is still the same version, or raise an exception + LOG.debug('Executing deploy step %s', step) + hardware.cache_node(node) + hardware.check_versions(deploy_version) + + if 'step' not in step: + msg = 'Malformed deploy_step, no "step" key: %s' % step + LOG.error(msg) + raise ValueError(msg) + try: + result = hardware.dispatch_to_managers(step['step'], node, ports, + **kwargs) + except Exception as e: + msg = ('Error performing deploy_step %(step)s: %(err)s' % + {'step': step['step'], 'err': e}) + LOG.exception(msg) + raise errors.DeploymentError(msg) + + LOG.info('Deploy step completed: %(step)s, result: %(result)s', + {'step': step, 'result': result}) + + # Cast result tuples (like output of utils.execute) as lists, or + # API throws errors + if isinstance(result, tuple): + result = list(result) + + # Return the step that was executed so we can dispatch + # to the appropriate Ironic interface + return { + 'deploy_result': result, + 'deploy_step': step + } diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py index de61b84cf..c5bf4b4db 100644 --- a/ironic_python_agent/hardware.py +++ b/ironic_python_agent/hardware.py @@ -14,6 +14,7 @@ import abc import binascii +import collections import functools import ipaddress import json @@ -720,6 +721,53 @@ class HardwareManager(object, metaclass=abc.ABCMeta): """ return [] + def get_deploy_steps(self, node, ports): + """Get a list of deploy steps with priority. + + Returns a list of steps. Each step is represented by a dict:: + + { + 'interface': the name of the driver interface that should execute + the step. + 'step': the HardwareManager function to call. + 'priority': the order steps will be run in. Ironic will sort all + the deploy steps from all the drivers, with the largest + priority step being run first. If priority is set to 0, + the step will not be run during deployment, but may be + run during zapping. + 'reboot_requested': Whether the agent should request Ironic reboots + the node via the power driver after the + operation completes. + } + + + If multiple hardware managers return the same step name, the following + logic will be used to determine which manager's step "wins": + + * Keep the step that belongs to HardwareManager with highest + HardwareSupport (larger int) value. + * If equal support level, keep the step with the higher defined + priority (larger int). + * If equal support level and priority, keep the step associated + with the HardwareManager whose name comes earlier in the + alphabet. + + The steps will be called using `hardware.dispatch_to_managers` and + handled by the best suited hardware manager. If you need a step to be + executed by only your hardware manager, ensure it has a unique step + name. + + `node` and `ports` can be used by other hardware managers to further + determine if a deploy step is supported for the node. + + :param node: Ironic node object + :param ports: list of Ironic port objects + :return: a list of deploying steps, where each step is described as a + dict as defined above + + """ + return [] + def get_version(self): """Get a name and version for this hardware manager. @@ -1486,6 +1534,22 @@ class GenericHardwareManager(HardwareManager): } ] + def get_deploy_steps(self, node, ports): + return [ + { + 'step': 'delete_configuration', + 'priority': 0, + 'interface': 'raid', + 'reboot_requested': False, + }, + { + 'step': 'create_configuration', + 'priority': 0, + 'interface': 'raid', + 'reboot_requested': False, + }, + ] + def create_configuration(self, node, ports): """Create a RAID configuration. @@ -2054,3 +2118,102 @@ def cache_node(node): def get_cached_node(): """Guard function around the module variable NODE.""" return NODE + + +def get_current_versions(): + """Fetches versions from all hardware managers. + + :returns: Dict in the format {name: version} containing one entry for + every hardware manager. + """ + return {version.get('name'): version.get('version') + for version in dispatch_to_all_managers('get_version').values()} + + +def check_versions(provided_version=None): + """Ensure the version of hardware managers hasn't changed. + + :param provided_version: Hardware manager versions used by ironic. + :raises: errors.VersionMismatch if any hardware manager version on + the currently running agent doesn't match the one stored in + provided_version. + :returns: None + """ + # If the version is None, assume this is the first run + if provided_version is None: + return + agent_version = get_current_versions() + if provided_version != agent_version: + LOG.warning('Mismatched hardware managers versions. Agent version: ' + '%(agent)s, node version: %(node)s', + {'agent': agent_version, 'node': provided_version}) + raise errors.VersionMismatch(agent_version=agent_version, + node_version=provided_version) + + +def deduplicate_steps(candidate_steps): + """Remove duplicated clean or deploy steps + + Deduplicates steps returned from HardwareManagers to prevent running + a given step more than once. Other than individual step priority, + it doesn't actually impact the deployment which specific steps are kept + and what HardwareManager they are associated with. + However, in order to make testing easier, this method returns + deterministic results. + + Uses the following filtering logic to decide which step "wins": + + - Keep the step that belongs to HardwareManager with highest + HardwareSupport (larger int) value. + - If equal support level, keep the step with the higher defined priority + (larger int). + - If equal support level and priority, keep the step associated with the + HardwareManager whose name comes earlier in the alphabet. + + :param candidate_steps: A dict containing all possible steps from + all managers, key=manager, value=list of steps + :returns: A deduplicated dictionary of {hardware_manager: [steps]} + """ + support = dispatch_to_all_managers( + 'evaluate_hardware_support') + + steps = collections.defaultdict(list) + deduped_steps = collections.defaultdict(list) + + for manager, manager_steps in candidate_steps.items(): + # We cannot deduplicate steps with unknown hardware support + if manager not in support: + LOG.warning('Unknown hardware support for %(manager)s, ' + 'dropping steps: %(steps)s', + {'manager': manager, 'steps': manager_steps}) + continue + + for step in manager_steps: + # build a new dict of steps that's easier to filter + step['hwm'] = {'name': manager, + 'support': support[manager]} + steps[step['step']].append(step) + + for step_name, step_list in steps.items(): + # determine the max support level among candidate steps + max_support = max([x['hwm']['support'] for x in step_list]) + # filter out any steps that are not at the max support for this step + max_support_steps = [x for x in step_list + if x['hwm']['support'] == max_support] + + # determine the max priority among remaining steps + max_priority = max([x['priority'] for x in max_support_steps]) + # filter out any steps that are not at the max priority for this step + max_priority_steps = [x for x in max_support_steps + if x['priority'] == max_priority] + + # if there are still multiple steps, sort by hwm name and take + # the first result + winning_step = sorted(max_priority_steps, + key=lambda x: x['hwm']['name'])[0] + # Remove extra metadata we added to the step for filtering + manager = winning_step.pop('hwm')['name'] + # Add winning step to deduped_steps + deduped_steps[manager].append(winning_step) + + return deduped_steps diff --git a/ironic_python_agent/tests/unit/extensions/test_clean.py b/ironic_python_agent/tests/unit/extensions/test_clean.py index 3e510d302..345c37228 100644 --- a/ironic_python_agent/tests/unit/extensions/test_clean.py +++ b/ironic_python_agent/tests/unit/extensions/test_clean.py @@ -34,8 +34,8 @@ class TestCleanExtension(base.IronicAgentTest): } self.version = {'generic': '1', 'specific': '1'} - @mock.patch('ironic_python_agent.extensions.clean.' - '_get_current_clean_version', autospec=True) + @mock.patch('ironic_python_agent.hardware.get_current_versions', + autospec=True) @mock.patch('ironic_python_agent.hardware.dispatch_to_all_managers', autospec=True) def test_get_clean_steps(self, mock_dispatch, mock_version, @@ -141,7 +141,7 @@ class TestCleanExtension(base.IronicAgentTest): @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', autospec=True) - @mock.patch('ironic_python_agent.extensions.clean._check_clean_version', + @mock.patch('ironic_python_agent.hardware.check_versions', autospec=True) def test_execute_clean_step(self, mock_version, mock_dispatch, mock_cache_node): @@ -167,7 +167,7 @@ class TestCleanExtension(base.IronicAgentTest): @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', autospec=True) - @mock.patch('ironic_python_agent.extensions.clean._check_clean_version', + @mock.patch('ironic_python_agent.hardware.check_versions', autospec=True) def test_execute_clean_step_tuple_result(self, mock_version, mock_dispatch, mock_cache_node): @@ -191,7 +191,7 @@ class TestCleanExtension(base.IronicAgentTest): self.assertEqual(expected_result, async_result.command_result) mock_cache_node.assert_called_once_with(self.node) - @mock.patch('ironic_python_agent.extensions.clean._check_clean_version', + @mock.patch('ironic_python_agent.hardware.check_versions', autospec=True) def test_execute_clean_step_no_step(self, mock_version, mock_cache_node): async_result = self.agent_extension.execute_clean_step( @@ -205,7 +205,7 @@ class TestCleanExtension(base.IronicAgentTest): @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', autospec=True) - @mock.patch('ironic_python_agent.extensions.clean._check_clean_version', + @mock.patch('ironic_python_agent.hardware.check_versions', autospec=True) def test_execute_clean_step_fail(self, mock_version, mock_dispatch, mock_cache_node): @@ -226,12 +226,12 @@ class TestCleanExtension(base.IronicAgentTest): @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', autospec=True) - @mock.patch('ironic_python_agent.extensions.clean._check_clean_version', + @mock.patch('ironic_python_agent.hardware.check_versions', autospec=True) def test_execute_clean_step_version_mismatch(self, mock_version, mock_dispatch, mock_cache_node): - mock_version.side_effect = errors.CleanVersionMismatch( + mock_version.side_effect = errors.VersionMismatch( {'GenericHardwareManager': 1}, {'GenericHardwareManager': 2}) async_result = self.agent_extension.execute_clean_step( @@ -241,24 +241,3 @@ class TestCleanExtension(base.IronicAgentTest): self.assertEqual('CLEAN_VERSION_MISMATCH', async_result.command_status) mock_version.assert_called_once_with(self.version) - - -@mock.patch('ironic_python_agent.hardware.dispatch_to_all_managers', - autospec=True) -class TestCleanVersion(base.IronicAgentTest): - version = {'generic': '1', 'specific': '1'} - - def test__get_current_clean_version(self, mock_dispatch): - mock_dispatch.return_value = {'SpecificHardwareManager': - {'name': 'specific', 'version': '1'}, - 'GenericHardwareManager': - {'name': 'generic', 'version': '1'}} - self.assertEqual(self.version, clean._get_current_clean_version()) - - def test__check_clean_version_fail(self, mock_dispatch): - mock_dispatch.return_value = {'SpecificHardwareManager': - {'name': 'specific', 'version': '1'}} - - self.assertRaises(errors.CleanVersionMismatch, - clean._check_clean_version, - {'not_specific': '1'}) diff --git a/ironic_python_agent/tests/unit/extensions/test_deploy.py b/ironic_python_agent/tests/unit/extensions/test_deploy.py new file mode 100644 index 000000000..531545f13 --- /dev/null +++ b/ironic_python_agent/tests/unit/extensions/test_deploy.py @@ -0,0 +1,241 @@ +# Licensed 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. + +import mock + +from ironic_python_agent import errors +from ironic_python_agent.extensions import deploy +from ironic_python_agent.tests.unit import base + + +@mock.patch('ironic_python_agent.hardware.cache_node', autospec=True) +class TestDeployExtension(base.IronicAgentTest): + def setUp(self): + super(TestDeployExtension, self).setUp() + self.agent_extension = deploy.DeployExtension() + self.node = {'uuid': 'dda135fb-732d-4742-8e72-df8f3199d244'} + self.ports = [] + self.step = { + 'GenericHardwareManager': + [{'step': 'erase_devices', + 'priority': 10, + 'interface': 'deploy'}] + } + self.version = {'generic': '1', 'specific': '1'} + + @mock.patch('ironic_python_agent.hardware.get_current_versions', + autospec=True) + @mock.patch('ironic_python_agent.hardware.dispatch_to_all_managers', + autospec=True) + def test_get_deploy_steps(self, mock_dispatch, mock_version, + mock_cache_node): + mock_version.return_value = self.version + + manager_steps = { + 'SpecificHardwareManager': [ + { + 'step': 'erase_devices', + 'priority': 10, + 'interface': 'deploy', + 'reboot_requested': False + }, + { + 'step': 'upgrade_bios', + 'priority': 20, + 'interface': 'deploy', + 'reboot_requested': True + }, + { + 'step': 'upgrade_firmware', + 'priority': 60, + 'interface': 'deploy', + 'reboot_requested': False + }, + ], + 'FirmwareHardwareManager': [ + { + 'step': 'upgrade_firmware', + 'priority': 10, + 'interface': 'deploy', + 'reboot_requested': False + }, + { + 'step': 'erase_devices', + 'priority': 40, + 'interface': 'deploy', + 'reboot_requested': False + }, + ], + 'DiskHardwareManager': [ + { + 'step': 'erase_devices', + 'priority': 50, + 'interface': 'deploy', + 'reboot_requested': False + }, + ] + } + + expected_steps = { + 'SpecificHardwareManager': [ + # Only manager upgrading BIOS + { + 'step': 'upgrade_bios', + 'priority': 20, + 'interface': 'deploy', + 'reboot_requested': True + } + ], + 'FirmwareHardwareManager': [ + # Higher support than specific, even though lower priority + { + 'step': 'upgrade_firmware', + 'priority': 10, + 'interface': 'deploy', + 'reboot_requested': False + }, + ], + 'DiskHardwareManager': [ + # Higher support than specific, higher priority than firmware + { + 'step': 'erase_devices', + 'priority': 50, + 'interface': 'deploy', + 'reboot_requested': False + }, + ] + + } + + hardware_support = { + 'SpecificHardwareManager': 3, + 'FirmwareHardwareManager': 4, + 'DiskHardwareManager': 4 + } + + mock_dispatch.side_effect = [manager_steps, hardware_support] + expected_return = { + 'hardware_manager_version': self.version, + 'deploy_steps': expected_steps + } + + async_results = self.agent_extension.get_deploy_steps(node=self.node, + ports=self.ports) + + # Ordering of the deploy steps doesn't matter; they're sorted by + # 'priority' in Ironic + self.assertEqual(expected_return, + async_results.join().command_result) + mock_cache_node.assert_called_once_with(self.node) + + @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', + autospec=True) + @mock.patch('ironic_python_agent.hardware.check_versions', + autospec=True) + def test_execute_deploy_step(self, mock_version, mock_dispatch, + mock_cache_node): + result = 'deployed' + mock_dispatch.return_value = result + + expected_result = { + 'deploy_step': self.step['GenericHardwareManager'][0], + 'deploy_result': result + } + async_result = self.agent_extension.execute_deploy_step( + step=self.step['GenericHardwareManager'][0], + node=self.node, ports=self.ports, + deploy_version=self.version) + async_result.join() + + mock_version.assert_called_once_with(self.version) + mock_dispatch.assert_called_once_with( + self.step['GenericHardwareManager'][0]['step'], + self.node, self.ports) + self.assertEqual(expected_result, async_result.command_result) + mock_cache_node.assert_called_once_with(self.node) + + @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', + autospec=True) + @mock.patch('ironic_python_agent.hardware.check_versions', + autospec=True) + def test_execute_deploy_step_tuple_result(self, mock_version, + mock_dispatch, mock_cache_node): + result = ('stdout', 'stderr') + mock_dispatch.return_value = result + + expected_result = { + 'deploy_step': self.step['GenericHardwareManager'][0], + 'deploy_result': ['stdout', 'stderr'] + } + async_result = self.agent_extension.execute_deploy_step( + step=self.step['GenericHardwareManager'][0], + node=self.node, ports=self.ports, + deploy_version=self.version) + async_result.join() + + mock_version.assert_called_once_with(self.version) + mock_dispatch.assert_called_once_with( + self.step['GenericHardwareManager'][0]['step'], + self.node, self.ports) + self.assertEqual(expected_result, async_result.command_result) + mock_cache_node.assert_called_once_with(self.node) + + @mock.patch('ironic_python_agent.hardware.check_versions', + autospec=True) + def test_execute_deploy_step_no_step(self, mock_version, mock_cache_node): + async_result = self.agent_extension.execute_deploy_step( + step={}, node=self.node, ports=self.ports, + deploy_version=self.version) + async_result.join() + + self.assertEqual('FAILED', async_result.command_status) + mock_version.assert_called_once_with(self.version) + mock_cache_node.assert_called_once_with(self.node) + + @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', + autospec=True) + @mock.patch('ironic_python_agent.hardware.check_versions', + autospec=True) + def test_execute_deploy_step_fail(self, mock_version, mock_dispatch, + mock_cache_node): + mock_dispatch.side_effect = RuntimeError + + async_result = self.agent_extension.execute_deploy_step( + step=self.step['GenericHardwareManager'][0], node=self.node, + ports=self.ports, deploy_version=self.version) + async_result.join() + + self.assertEqual('FAILED', async_result.command_status) + + mock_version.assert_called_once_with(self.version) + mock_dispatch.assert_called_once_with( + self.step['GenericHardwareManager'][0]['step'], + self.node, self.ports) + mock_cache_node.assert_called_once_with(self.node) + + @mock.patch('ironic_python_agent.hardware.dispatch_to_managers', + autospec=True) + @mock.patch('ironic_python_agent.hardware.check_versions', + autospec=True) + def test_execute_deploy_step_version_mismatch(self, mock_version, + mock_dispatch, + mock_cache_node): + mock_version.side_effect = errors.VersionMismatch( + {'GenericHardwareManager': 1}, {'GenericHardwareManager': 2}) + + async_result = self.agent_extension.execute_deploy_step( + step=self.step['GenericHardwareManager'][0], node=self.node, + ports=self.ports, deploy_version=self.version) + async_result.join() + self.assertEqual('CLEAN_VERSION_MISMATCH', async_result.command_status) + + mock_version.assert_called_once_with(self.version) diff --git a/ironic_python_agent/tests/unit/test_hardware.py b/ironic_python_agent/tests/unit/test_hardware.py index c88809db8..11c2d181c 100644 --- a/ironic_python_agent/tests/unit/test_hardware.py +++ b/ironic_python_agent/tests/unit/test_hardware.py @@ -4190,3 +4190,24 @@ def create_hdparm_info(supported=False, enabled=False, locked=False, update_values(values, enhanced_erase, 'enhanced_erase') return HDPARM_INFO_TEMPLATE % values + + +@mock.patch('ironic_python_agent.hardware.dispatch_to_all_managers', + autospec=True) +class TestVersions(base.IronicAgentTest): + version = {'generic': '1', 'specific': '1'} + + def test_get_current_versions(self, mock_dispatch): + mock_dispatch.return_value = {'SpecificHardwareManager': + {'name': 'specific', 'version': '1'}, + 'GenericHardwareManager': + {'name': 'generic', 'version': '1'}} + self.assertEqual(self.version, hardware.get_current_versions()) + + def test_check_versions(self, mock_dispatch): + mock_dispatch.return_value = {'SpecificHardwareManager': + {'name': 'specific', 'version': '1'}} + + self.assertRaises(errors.VersionMismatch, + hardware.check_versions, + {'not_specific': '1'}) diff --git a/setup.cfg b/setup.cfg index f8b7174f3..851b13286 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,6 +32,7 @@ console_scripts = ironic_python_agent.extensions = standby = ironic_python_agent.extensions.standby:StandbyExtension clean = ironic_python_agent.extensions.clean:CleanExtension + deploy = ironic_python_agent.extensions.deploy:DeployExtension flow = ironic_python_agent.extensions.flow:FlowExtension iscsi = ironic_python_agent.extensions.iscsi:ISCSIExtension image = ironic_python_agent.extensions.image:ImageExtension