diff --git a/etc/ironic/ironic.conf.sample b/etc/ironic/ironic.conf.sample index 4e18e2e091..c8e7bcd713 100644 --- a/etc/ironic/ironic.conf.sample +++ b/etc/ironic/ironic.conf.sample @@ -891,6 +891,16 @@ # From ironic # +# Number of retries in the case of a failed action (only +# specific actions are retried). This option is part of boot- +# from-volume work, which is not currently exposed to users. +# (integer value) +#action_retries = 3 + +# Retry interval in seconds in the case of a failed action +# (only specific actions are retried). (integer value) +#action_retry_interval = 5 + # Authentication URL (string value) #auth_url = diff --git a/ironic/conf/cinder.py b/ironic/conf/cinder.py index dfffa58b14..67460674a4 100644 --- a/ironic/conf/cinder.py +++ b/ironic/conf/cinder.py @@ -28,6 +28,16 @@ opts = [ help=_('Client retries in the case of a failed request ' 'connection. This option is part of boot-from-volume ' 'work, which is not currently exposed to users.')), + cfg.IntOpt('action_retries', + default=3, + help=_('Number of retries in the case of a failed ' + 'action (currently only used when deatching' + 'volumes). This option is part of boot-from-volume ' + 'work, which is not currently exposed to users.')), + cfg.IntOpt('action_retry_interval', + default=5, + help=_('Retry interval in seconds in the case of a failed ' + 'action (only specific actions are retried).')), ] diff --git a/ironic/drivers/modules/storage/cinder.py b/ironic/drivers/modules/storage/cinder.py new file mode 100644 index 0000000000..7c797e4c44 --- /dev/null +++ b/ironic/drivers/modules/storage/cinder.py @@ -0,0 +1,430 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP. +# Copyright 2016 IBM Corp +# 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 retrying + +from oslo_config import cfg +from oslo_log import log +from oslo_utils import excutils +from oslo_utils import strutils + +from ironic.common import cinder +from ironic.common import exception +from ironic.common.i18n import _ +from ironic.common import states +from ironic.drivers import base +from ironic.drivers import utils +from ironic import objects + +CONF = cfg.CONF + +LOG = log.getLogger(__name__) + +# NOTE(TheJulia): Sets containing known valid types that align with +# _generate_connector() and the volume connection information spec. +VALID_ISCSI_TYPES = ('iqn',) +# TODO(TheJulia): FCoE? +VALID_FC_TYPES = ('wwpn', 'wwnn') + + +class CinderStorage(base.StorageInterface): + """A storage_interface driver supporting Cinder.""" + + def get_properties(self): + """Return the properties of the interface. + + :returns: dictionary of : entries. + """ + return {} + + def _fail_validation(self, task, reason, + exception=exception.InvalidParameterValue): + msg = (_("Failed to validate cinder storage interface for node " + "%(node)s. %(reason)s") % + {'node': task.node.uuid, 'reason': reason}) + LOG.error(msg) + raise exception(msg) + + def _validate_connectors(self, task): + """Validate connector information helper. + + Enumerates through all connector objects, and identifies if + iSCSI or Fibre Channel connectors are present. + + :param task: The task object. + :raises InvalidParameterValue: If iSCSI is identified and + iPXE is disabled. + :raises StorageError: If the number of wwpns is not equal to + the number of wwnns + :returns: Dictionary containing iscsi_found and fc_found + keys with boolean values representing if the + helper found that connector type configured + for the node. + """ + + node = task.node + iscsi_uuids_found = [] + wwpn_found = 0 + wwnn_found = 0 + ipxe_enabled = CONF.pxe.ipxe_enabled + + for connector in task.volume_connectors: + if (connector.type in VALID_ISCSI_TYPES and + connector.connector_id is not None): + iscsi_uuids_found.append(connector.uuid) + if not ipxe_enabled: + msg = _("The [pxe]/ipxe_enabled option must " + "be set to True to support network " + "booting to an iSCSI volume.") + self._fail_validation(task, msg) + + if (connector.type in VALID_FC_TYPES and + connector.connector_id is not None): + # NOTE(TheJulia): Unlike iSCSI with cinder, we have no need + # to warn about multiple IQN entries, since we are able to + # submit multiple fibre channel WWPN entries. + if connector.type == 'wwpn': + wwpn_found += 1 + if connector.type == 'wwnn': + wwnn_found += 1 + if len(iscsi_uuids_found) > 1: + LOG.warning("Multiple possible iSCSI connectors, " + "%(iscsi_uuids_found) found, for node %(node)s. " + "Only the first iSCSI connector, %(iscsi_uuid)s, " + "will be utilized.", + {'node': node.uuid, + 'iscsi_uuids_found': iscsi_uuids_found, + 'iscsi_uuid': iscsi_uuids_found[0]}) + if wwpn_found != wwnn_found: + msg = _("Cinder requires both wwnn and wwpn entries for FCoE " + "connections. There must be a wwpn entry for every wwnn " + "entry. There are %(wwpn)d wwpn entries and %(wwnn)s wwnn " + "entries.") % {'wwpn': wwpn_found, 'wwnn': wwnn_found} + self._fail_validation(task, msg, exception.StorageError) + return {'fc_found': wwpn_found >= 1, + 'iscsi_found': len(iscsi_uuids_found) >= 1} + + def _validate_targets(self, task, found_types, iscsi_boot, fc_boot): + """Validate target information helper. + + Enumerates through all target objects and identifies if + iSCSI or Fibre Channel targets are present, and matches the + connector capability of the node. + + :param task: The task object. + :param found_types: Dictionary containing boolean values returned + from the _validate_connectors helper method. + :param iscsi_boot: Boolean value indicating if iSCSI boot operations + are available. + :param fc_boot: Boolean value indicating if Fibre Channel boot + operations are available. + :raises: InvalidParameterValue + """ + + for volume in task.volume_targets: + if volume.volume_id is None: + msg = (_("volume_id missing from target %(id)s.") % + {'id': volume.uuid}) + self._fail_validation(task, msg) + + # NOTE(TheJulia): We should likely consider incorporation + # of the volume boot_index field, however it may not be + # relevant to the checks we perform here as in the end a + # FC volume "attached" to a node is a valid configuration + # as well. + # TODO(TheJulia): When we create support in nova to record + # that a volume attachment is going to take place, we will + # likely need to match the driver_volume_type field to + # our generic volume_type field. NB The LVM driver appears + # to not use that convention in cinder, as it is freeform. + if volume.volume_type == 'fibre_channel': + if not fc_boot and volume.boot_index == 0: + msg = (_("Volume target %(id)s is configured for " + "'fibre_channel', however the capability " + "'fibre_channel_boot' is not set on node.") % + {'id': volume.uuid}) + self._fail_validation(task, msg) + if not found_types['fc_found']: + msg = (_("Volume target %(id)s is configured for " + "'fibre_channel', however no Fibre Channel " + "WWPNs are configured for the node volume " + "connectors.") % + {'id': volume.uuid}) + self._fail_validation(task, msg) + + elif volume.volume_type == 'iscsi': + if not iscsi_boot and volume.boot_index == 0: + msg = (_("Volume target %(id)s is configured for " + "'iscsi', however the capability 'iscsi_boot' " + "is not set for the node.") % + {'id': volume.uuid}) + self._fail_validation(task, msg) + if not found_types['iscsi_found']: + msg = (_("Volume target %(id)s is configured for " + "'iscsi', however no iSCSI connectors are " + "configured for the node.") % + {'id': volume.uuid}) + self._fail_validation(task, msg) + else: + # NOTE(TheJulia); The note below needs to be updated + # whenever support for additional volume types are added. + msg = (_("Volume target %(id)s is of an unknown type " + "'%(type)s'. Supported types: 'iscsi' or " + "'fibre_channel'") % + {'id': volume.uuid, 'type': volume.volume_type}) + self._fail_validation(task, msg) + + def validate(self, task): + """Validate storage_interface configuration for Cinder usage. + + In order to provide fail fast functionality prior to nodes being + requested to enter the active state, this method performs basic + checks of the volume connectors, volume targets, and operator + defined capabilities. These checks are to help ensure that we + should have a compatible configuration prior to activating the + node. + + :param task: The task object. + :raises: InvalidParameterValue If a misconfiguration or mismatch + exists that would prevent storage the cinder storage + driver from initializing attachments. + """ + + found_types = self._validate_connectors(task) + node = task.node + iscsi_boot = strutils.bool_from_string( + utils.get_node_capability(node, 'iscsi_boot')) + fc_boot = strutils.bool_from_string( + utils.get_node_capability(node, 'fibre_channel_boot')) + + # Validate capability configuration against configured volumes + # such that we raise errors for missing connectors if the + # boot capability is defined. + if iscsi_boot and not found_types['iscsi_found']: + valid_types = ', '.join(VALID_ISCSI_TYPES) + msg = (_("In order to enable the 'iscsi_boot' capability for " + "the node, an associated volume_connector type " + "must be valid for iSCSI (%(options)s).") % + {'options': valid_types}) + self._fail_validation(task, msg) + + if fc_boot and not found_types['fc_found']: + valid_types = ', '.join(VALID_FC_TYPES) + msg = (_("In order to enable the 'fibre_channel_boot' capability " + "for the node, an associated volume_connector type must " + "be valid for Fibre Channel (%(options)s).") % + {'options': valid_types}) + self._fail_validation(task, msg) + + self._validate_targets(task, found_types, iscsi_boot, fc_boot) + + def _abort_attach_volume(self, task, connector): + """Detach volumes as a result of failed attachment. + + If detachment fails, it will be retried with allow_errors=True. + + :param task: The task object. + :param connector: The dictionary representing a node's connectivity + as define by _generate_connector. + """ + targets = [target.volume_id for target in task.volume_targets] + try: + cinder.detach_volumes(task, targets, connector) + except exception.StorageError as e: + LOG.warning("Error detaching volume for node %(node)s on " + "aborting volume attach: %(err)s. Retrying with " + "errors allowed.", {'node': task.node.uuid, 'err': e}) + cinder.detach_volumes(task, targets, connector, allow_errors=True) + + def attach_volumes(self, task): + """Informs the storage subsystem to attach all volumes for the node. + + :param task: The task object. + :raises: StorageError If an underlying exception or failure + is detected. + """ + node = task.node + targets = [target.volume_id for target in task.volume_targets] + + # If there are no targets, then we have nothing to do. + if not targets: + return + + connector = self._generate_connector(task) + try: + connected = cinder.attach_volumes(task, targets, connector) + except exception.StorageError as e: + with excutils.save_and_reraise_exception(): + LOG.error("Error attaching volumes for node %(node)s: " + "%(err)s", {'node': node.uuid, 'err': e}) + self._abort_attach_volume(task, connector) + + if len(targets) != len(connected): + LOG.error("The number of volumes defined for node %(node)s does " + "not match the number of attached volumes. Attempting " + "detach and abort operation.", {'node': node.uuid}) + self._abort_attach_volume(task, connector) + raise exception.StorageError(("Mismatch between the number of " + "configured volume targets for " + "node %(uuid)s and the number of " + "completed attachments.") % + {'uuid': node.uuid}) + + for volume in connected: + volume_uuid = volume['data']['ironic_volume_uuid'] + targets = objects.VolumeTarget.list_by_volume_id(task.context, + volume_uuid) + + for target in targets: + # Volumes that were already attached are + # skipped. Updating target volume properties + # for these volumes is nova's responsibility. + if not volume.get('already_attached'): + target.properties = volume['data'] + target.save() + + def detach_volumes(self, task): + """Informs the storage subsystem to detach all volumes for the node. + + This action is retried in case of failure. + + :param task: The task object. + :raises: StorageError If an underlying exception or failure + is detected. + """ + # TODO(TheJulia): Ideally we should query the cinder API and reconcile + # or add any missing volumes and initiate detachments. + node = task.node + targets = [target.volume_id for target in task.volume_targets] + + # If there are no targets, then we have nothing to do. + if not targets: + return + + connector = self._generate_connector(task) + + @retrying.retry( + retry_on_exception=lambda e: isinstance(e, exception.StorageError), + stop_max_attempt_number=CONF.cinder.action_retries + 1, + wait_fixed=CONF.cinder.action_retry_interval * 1000) + def detach_volumes(): + try: + # NOTE(TheJulia): If the node is in ACTIVE state, we can + # tolerate failures detaching as the node is likely being + # powered down to cause a detachment event. + allow_errors = (task.node.provision_state == states.ACTIVE) + cinder.detach_volumes(task, targets, connector, + allow_errors=allow_errors) + except exception.StorageError as e: + # NOTE(TheJulia): In the event that the node is not in + # ACTIVE state, we need to fail hard as we need to ensure + # all attachments are removed. + msg = ("Error detaching volumes for " + "node %(node)s: %(err)s.") % {'node': node.uuid, + 'err': e} + if outer_args['attempt'] < CONF.cinder.action_retries: + outer_args['attempt'] += 1 + msg += " Re-attempting detachment." + LOG.warning(msg) + else: + LOG.error(msg) + raise + + # NOTE(mjturek): This dict is used by detach_volumes to determine + # if this is the last attempt. This is a dict rather than an int + # so that it is mutable by the inner function. In python3 this is + # possible with the 'nonlocal' keyword which is unfortunately not + # available in python2. + outer_args = {'attempt': 0} + detach_volumes() + + def should_write_image(self, task): + """Determines if deploy should perform the image write-out. + + NOTE: This method will be written as part of the changes to the + boot logic, and for now should always return false to indicate + that the deployment image write-out process should be skipped. + + :param task: The task object. + :returns: True if the deployment write-out process should be + executed. + """ + return False + + def _generate_connector(self, task): + """Generate cinder connector value based upon the node. + + Generates cinder compatible connector information for the purpose of + attaching volumes. Translation: We need to tell the storage where and + possibly how we can connect. + + Supports passing iSCSI information in the form of IP and IQN records, + as well as Fibre Channel information in the form of WWPN addresses. + Fibre Channel WWNN addresses are also sent, however at present in-tree + Cinder drivers do not utilize WWNN addresses. + + If multiple connectors exist, the request will be filed with + MultiPath IO being enabled. + + A warning is logged if an unsupported volume type is encountered. + + :params task: The task object. + + :returns: A dictionary data structure similar to: + {'ip': ip, + 'initiator': iqn, + 'multipath: True, + 'wwpns': ['WWN1', 'WWN2']} + :raises: StorageError upon no valid connector record being identified. + """ + data = {} + valid = False + for connector in task.volume_connectors: + if 'iqn' in connector.type and 'initiator' not in data: + data['initiator'] = connector.connector_id + valid = True + elif 'ip' in connector.type and 'ip' not in data: + data['ip'] = connector.connector_id + # TODO(TheJulia): Translate to, or generate an IQN. + elif 'wwpn' in connector.type: + data.setdefault('wwpns', []).append(connector.connector_id) + valid = True + elif 'wwnn' in connector.type: + data.setdefault('wwnns', []).append(connector.connector_id) + valid = True + else: + # TODO(jtaryma): Add handling of type 'mac' with MAC to IP + # translation. + LOG.warning('Node %(node)s has a volume_connector (%(uuid)s) ' + 'defined with an unsupported type: %(type)s.', + {'node': task.node.uuid, + 'uuid': connector.uuid, + 'type': connector.type}) + if not valid: + valid_types = ', '.join(VALID_FC_TYPES + VALID_ISCSI_TYPES) + msg = (_('Insufficient or incompatible volume connection ' + 'records for node %(uuid)s. Valid connector ' + 'types: %(types)s') % + {'uuid': task.node.uuid, 'types': valid_types}) + LOG.error(msg) + raise exception.StorageError(msg) + + # NOTE(TheJulia): Hostname appears to only be used for logging + # in cinder drivers, however that may not always be true, and + # may need to change over time. + data['host'] = task.node.uuid + if len(task.volume_connectors) > 1 and len(data) > 1: + data['multipath'] = True + + return data diff --git a/ironic/tests/unit/drivers/modules/storage/__init__.py b/ironic/tests/unit/drivers/modules/storage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ironic/tests/unit/drivers/modules/storage/test_cinder.py b/ironic/tests/unit/drivers/modules/storage/test_cinder.py new file mode 100644 index 0000000000..696a035ebb --- /dev/null +++ b/ironic/tests/unit/drivers/modules/storage/test_cinder.py @@ -0,0 +1,592 @@ +# Copyright 2016 Hewlett Packard Enterprise Development Company LP. +# Copyright 2016 IBM Corp +# 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 oslo_utils import uuidutils + +from ironic.common import cinder as cinder_common +from ironic.common import exception +from ironic.common import states +from ironic.conductor import task_manager +from ironic.drivers.modules.storage import cinder +from ironic.drivers import utils as driver_utils +from ironic import objects +from ironic.tests.unit.conductor import mgr_utils +from ironic.tests.unit.db import base as db_base +from ironic.tests.unit.objects import utils as object_utils + + +class CinderInterfaceTestCase(db_base.DbTestCase): + + def setUp(self): + super(CinderInterfaceTestCase, self).setUp() + self.config(ipxe_enabled=True, + group='pxe') + self.config(action_retries=3, + action_retry_interval=0, + group='cinder') + self.config(enabled_drivers=['fake']) + self.config(enabled_storage_interfaces=['noop', 'cinder']) + mgr_utils.mock_the_extension_manager() + self.interface = cinder.CinderStorage() + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test__fail_validation(self, mock_log): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + """Ensure the validate helper logs and raises exceptions.""" + fake_error = 'a error!' + expected = ("Failed to validate cinder storage interface for node " + "%s. a error!" % self.node.uuid) + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises(exception.InvalidParameterValue, + self.interface._fail_validation, + task, + fake_error) + mock_log.error.assert_called_with(expected) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test__generate_connector_raises_with_insufficent_data(self, mock_log): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises(exception.StorageError, + self.interface._generate_connector, + task) + self.assertTrue(mock_log.error.called) + + def test__generate_connector_iscsi(self): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + expected = { + 'initiator': 'iqn.address', + 'ip': 'ip.address', + 'host': self.node.uuid, + 'multipath': True} + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='ip', + connector_id='ip.address', uuid=uuidutils.generate_uuid()) + with task_manager.acquire(self.context, self.node.id) as task: + return_value = self.interface._generate_connector(task) + self.assertDictEqual(expected, return_value) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test__generate_connector_iscsi_and_unknown(self, mock_log): + """Validate we return and log with valid and invalid connectors.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + expected = { + 'initiator': 'iqn.address', + 'host': self.node.uuid, + 'multipath': True} + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='foo', + connector_id='bar', uuid=uuidutils.generate_uuid()) + with task_manager.acquire(self.context, self.node.id) as task: + return_value = self.interface._generate_connector(task) + self.assertDictEqual(expected, return_value) + self.assertEqual(1, mock_log.warning.call_count) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test__generate_connector_unknown_raises_excption(self, mock_log): + """Validate an exception is raised with only an invalid connector.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='foo', + connector_id='bar') + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises( + exception.StorageError, + self.interface._generate_connector, + task) + self.assertEqual(1, mock_log.warning.call_count) + self.assertEqual(1, mock_log.error.call_count) + + def test__generate_connector_single_path(self): + """Validate an exception is raised with only an invalid connector.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + expected = { + 'initiator': 'iqn.address', + 'host': self.node.uuid} + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + with task_manager.acquire(self.context, self.node.id) as task: + return_value = self.interface._generate_connector(task) + self.assertDictEqual(expected, return_value) + + def test__generate_connector_multiple_fc_wwns(self): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + """Validate handling of WWPNs and WWNNs.""" + expected = { + 'wwpns': ['wwpn1', 'wwpn2'], + 'wwnns': ['wwnn3', 'wwnn4'], + 'host': self.node.uuid, + 'multipath': True} + object_utils.create_test_volume_connector( + self.context, + node_id=self.node.id, + type='wwpn', + connector_id='wwpn1', + uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, + node_id=self.node.id, + type='wwpn', + connector_id='wwpn2', + uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, + node_id=self.node.id, + type='wwnn', + connector_id='wwnn3', + uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, + node_id=self.node.id, + type='wwnn', + connector_id='wwnn4', + uuid=uuidutils.generate_uuid()) + with task_manager.acquire(self.context, self.node.id) as task: + return_value = self.interface._generate_connector(task) + self.assertDictEqual(expected, return_value) + + @mock.patch.object(cinder.CinderStorage, '_fail_validation', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_success_no_settings(self, mock_log, mock_fail): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + with task_manager.acquire(self.context, self.node.id) as task: + self.interface.validate(task) + self.assertFalse(mock_fail.called) + self.assertFalse(mock_log.called) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_failure_if_iscsi_boot_no_connectors(self, mock_log): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + + valid_types = ', '.join(cinder.VALID_ISCSI_TYPES) + expected_msg = ("Failed to validate cinder storage interface for node " + "%(id)s. In order to enable the 'iscsi_boot' " + "capability for the node, an associated " + "volume_connector type must be valid for " + "iSCSI (%(options)s)." % + {'id': self.node.uuid, 'options': valid_types}) + + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, 'iscsi_boot', 'True') + self.assertRaises(exception.InvalidParameterValue, + self.interface.validate, + task) + mock_log.error.assert_called_once_with(expected_msg) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_failure_if_fc_boot_no_connectors(self, mock_log): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + valid_types = ', '.join(cinder.VALID_FC_TYPES) + expected_msg = ("Failed to validate cinder storage interface for node " + "%(id)s. In order to enable the 'fibre_channel_boot' " + "capability for the node, an associated " + "volume_connector type must be valid for " + "Fibre Channel (%(options)s)." % + {'id': self.node.uuid, 'options': valid_types}) + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, + 'fibre_channel_boot', + 'True') + self.assertRaises(exception.InvalidParameterValue, + self.interface.validate, + task) + mock_log.error.assert_called_once_with(expected_msg) + + @mock.patch.object(cinder.CinderStorage, '_fail_validation', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_success_iscsi_connector(self, mock_log, mock_fail): + """Perform validate with only an iSCSI connector in place.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + with task_manager.acquire(self.context, self.node.id) as task: + self.interface.validate(task) + self.assertFalse(mock_log.called) + self.assertFalse(mock_fail.called) + + @mock.patch.object(cinder.CinderStorage, '_fail_validation', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_success_fc_connectors(self, mock_log, mock_fail): + """Perform validate with only FC connectors in place""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwpn', + connector_id='wwpn.address', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwnn', + connector_id='wwnn.address', uuid=uuidutils.generate_uuid()) + with task_manager.acquire(self.context, self.node.id) as task: + self.interface.validate(task) + self.assertFalse(mock_log.called) + self.assertFalse(mock_fail.called) + + @mock.patch.object(cinder.CinderStorage, '_fail_validation', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_success_connectors_and_boot(self, mock_log, mock_fail): + """Perform validate with volume connectors and boot capabilities.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwpn', + connector_id='wwpn.address', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwnn', + connector_id='wwnn.address', uuid=uuidutils.generate_uuid()) + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, + 'fibre_channel_boot', + 'True') + driver_utils.add_node_capability(task, 'iscsi_boot', 'True') + self.interface.validate(task) + self.assertFalse(mock_log.called) + self.assertFalse(mock_fail.called) + + @mock.patch.object(cinder.CinderStorage, '_fail_validation', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_success_iscsi_targets(self, mock_log, mock_fail): + """Validate success with full iscsi scenario.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, 'iscsi_boot', 'True') + self.interface.validate(task) + self.assertFalse(mock_log.called) + self.assertFalse(mock_fail.called) + + @mock.patch.object(cinder.CinderStorage, '_fail_validation', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_success_fc_targets(self, mock_log, mock_fail): + """Validate success with full fc scenario.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwpn', + connector_id='fc.address', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwnn', + connector_id='fc.address', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='fibre_channel', + boot_index=0, volume_id='1234') + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, + 'fibre_channel_boot', + 'True') + self.interface.validate(task) + self.assertFalse(mock_log.called) + self.assertFalse(mock_fail.called) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_fails_with_ipxe_not_enabled(self, mock_log): + """Ensure a validation failure is raised when iPXE not enabled.""" + self.config(ipxe_enabled=False, group='pxe') + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='foo.address') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='2345') + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, 'iscsi_boot', 'True') + self.assertRaises(exception.InvalidParameterValue, + self.interface.validate, + task) + self.assertTrue(mock_log.error.called) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_fails_when_fc_connectors_unequal(self, mock_log): + """Validate should fail with only wwnn FC connector in place""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='wwnn', + connector_id='wwnn.address') + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises(exception.StorageError, + self.interface.validate, + task) + self.assertTrue(mock_log.error.called) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_fail_on_unknown_volume_types(self, mock_log): + """Ensure exception is raised when connector/target do not match.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='foo.address') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='wetcat', + boot_index=0, volume_id='1234') + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, 'iscsi_boot', 'True') + self.assertRaises(exception.InvalidParameterValue, + self.interface.validate, + task) + self.assertTrue(mock_log.error.called) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_fails_iscsi_conn_fc_target(self, mock_log): + """Validate failure of iSCSI connectors with FC target.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='foo.address') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='fibre_channel', + boot_index=0, volume_id='1234') + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, 'iscsi_boot', 'True') + self.assertRaises(exception.InvalidParameterValue, + self.interface.validate, + task) + self.assertTrue(mock_log.error.called) + + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_validate_fails_fc_conn_iscsi_target(self, mock_log): + """Validate failure of FC connectors with iSCSI target.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='fibre_channel', + connector_id='foo.address') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + with task_manager.acquire(self.context, self.node.id) as task: + driver_utils.add_node_capability(task, + 'fibre_channel_boot', + 'True') + self.assertRaises(exception.InvalidParameterValue, + self.interface.validate, + task) + self.assertTrue(mock_log.error.called) + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder_common, 'attach_volumes', autospec=True) + @mock.patch.object(cinder, 'LOG') + def test_attach_detach_volumes_no_volumes(self, mock_log, + mock_attach, mock_detach): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + with task_manager.acquire(self.context, self.node.id) as task: + self.interface.attach_volumes(task) + self.interface.detach_volumes(task) + + self.assertFalse(mock_attach.called) + self.assertFalse(mock_detach.called) + self.assertFalse(mock_log.called) + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder_common, 'attach_volumes', autospec=True) + def test_attach_detach_volumes_fails_without_connectors(self, + mock_attach, + mock_detach): + """Without connectors, attach and detach should fail.""" + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises(exception.StorageError, + self.interface.attach_volumes, task) + self.assertFalse(mock_attach.called) + self.assertRaises(exception.StorageError, + self.interface.detach_volumes, task) + self.assertFalse(mock_detach.called) + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder_common, 'attach_volumes', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + @mock.patch.object(objects.volume_target.VolumeTarget, 'list_by_volume_id') + def test_attach_detach_called_with_target_and_connector(self, + mock_target_list, + mock_log, + mock_attach, + mock_detach): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + target_uuid = uuidutils.generate_uuid() + test_volume_target = object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234', uuid=target_uuid) + + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + expected_target_properties = { + 'volume_id': '1234', + 'ironic_volume_uuid': target_uuid, + 'new_property': 'foo'} + mock_attach.return_value = [{ + 'driver_volume_type': 'iscsi', + 'data': expected_target_properties}] + mock_target_list.return_value = [test_volume_target] + with task_manager.acquire(self.context, self.node.id) as task: + self.interface.attach_volumes(task) + self.assertFalse(mock_log.called) + self.assertTrue(mock_attach.called) + task.volume_targets[0].refresh() + self.assertEqual(expected_target_properties, + task.volume_targets[0]['properties']) + self.interface.detach_volumes(task) + self.assertFalse(mock_log.called) + self.assertTrue(mock_detach.called) + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder_common, 'attach_volumes', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_attach_volumes_failure(self, mock_log, mock_attach, mock_detach): + """Verify detach is called upon attachment failing.""" + + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=1, volume_id='5678', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + + mock_attach.side_effect = exception.StorageError('foo') + + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises(exception.StorageError, + self.interface.attach_volumes, task) + self.assertTrue(mock_attach.called) + self.assertTrue(mock_detach.called) + # Replacing the mock to not return an error, should still raise an + # exception. + mock_attach.reset_mock() + mock_detach.reset_mock() + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder_common, 'attach_volumes', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_attach_volumes_failure_no_attach_error(self, mock_log, + mock_attach, mock_detach): + """Verify that detach is called on volume/connector mismatch. + + Volume attachment fails if the number of attachments completed + does not match the number of configured targets. + """ + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=1, volume_id='5678', uuid=uuidutils.generate_uuid()) + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + + mock_attach.return_value = {'mock_return'} + with task_manager.acquire(self.context, self.node.id) as task: + self.assertRaises(exception.StorageError, + self.interface.attach_volumes, task) + self.assertTrue(mock_attach.called) + self.assertTrue(mock_detach.called) + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder, 'LOG', autospec=True) + def test_detach_volumes_failure(self, mock_log, mock_detach): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + + with task_manager.acquire(self.context, self.node.id) as task: + # The first attempt should succeed. + # The second attempt should throw StorageError + # Third attempt, should log errors but not raise an exception. + mock_detach.side_effect = [None, + exception.StorageError('bar'), + None] + # This should generate 1 mock_detach call and succeed + self.interface.detach_volumes(task) + + task.node.provision_state = states.DELETED + # This should generate the other 2 moc_detach calls and warn + self.interface.detach_volumes(task) + self.assertEqual(3, mock_detach.call_count) + self.assertEqual(1, mock_log.warning.call_count) + + @mock.patch.object(cinder_common, 'detach_volumes', autospec=True) + @mock.patch.object(cinder, 'LOG') + def test_detach_volumes_failure_raises_exception(self, + mock_log, + mock_detach): + self.node = object_utils.create_test_node(self.context, + storage_interface='cinder') + object_utils.create_test_volume_target( + self.context, node_id=self.node.id, volume_type='iscsi', + boot_index=0, volume_id='1234') + object_utils.create_test_volume_connector( + self.context, node_id=self.node.id, type='iqn', + connector_id='iqn.address') + + with task_manager.acquire(self.context, self.node.id) as task: + mock_detach.side_effect = exception.StorageError('bar') + self.assertRaises(exception.StorageError, + self.interface.detach_volumes, + task) + # Check that we warn every retry except the last one. + self.assertEqual(3, mock_log.warning.call_count) + self.assertEqual(1, mock_log.error.call_count) + # CONF.cinder.action_retries + 1, number of retries is set to 3. + self.assertEqual(4, mock_detach.call_count) diff --git a/setup.cfg b/setup.cfg index 7077169c38..4022041ac4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -138,6 +138,7 @@ ironic.hardware.interfaces.rescue = ironic.hardware.interfaces.storage = fake = ironic.drivers.modules.fake:FakeStorage noop = ironic.drivers.modules.storage.noop:NoopStorage + cinder = ironic.drivers.modules.storage.cinder:CinderStorage ironic.hardware.interfaces.vendor = fake = ironic.drivers.modules.fake:FakeVendorB