summaryrefslogtreecommitdiff
path: root/ironic/drivers/modules/storage/cinder.py
blob: e2a0a7731a5bd0a31c922f09d2a8629af72c7577 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# 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.

from oslo_config import cfg
from oslo_log import log
from oslo_utils import excutils
from oslo_utils import strutils
import retrying

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 <property name>:<property description> 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 = False
        if 'pxe_boot' in task.driver.boot.capabilities:
            if CONF.pxe.ipxe_enabled:
                ipxe_enabled = True
        elif 'ipxe_boot' in task.driver.boot.capabilities:
            ipxe_enabled = True

        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 or the boot interface "
                            "must be set to ``ipxe`` 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)s 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 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.detach_volumes(task, connector=connector,
                                    aborting_attach=True)

        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.detach_volumes(task, connector=connector,
                                aborting_attach=True)
            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:
            # Volumes that were already attached are
            # skipped. Updating target volume properties
            # for these volumes is nova's responsibility.
            if not volume.get('already_attached'):
                volume_uuid = volume['data']['ironic_volume_uuid']
                targets = objects.VolumeTarget.list_by_volume_id(task.context,
                                                                 volume_uuid)

                for target in targets:
                    target.properties = volume['data']
                    target.save()

    def detach_volumes(self, task, connector=None, aborting_attach=False):
        """Informs the storage subsystem to detach all volumes for the node.

        This action is retried in case of failure.

        :param task: The task object.
        :param connector: The dictionary representing a node's connectivity
                          as defined by _generate_connector(). Generated
                          if not passed.
        :param aborting_attach: Boolean representing if this detachment
                                was requested to handle aborting a
                                failed attachment
        :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

        if not connector:
            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
                                or aborting_attach and outer_args['attempt']
                                > 0)
                cinder.detach_volumes(task, targets, connector,
                                      allow_errors=allow_errors)
            except exception.StorageError as e:
                with excutils.save_and_reraise_exception():
                    # 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.
                    if aborting_attach:
                        msg_format = ("Error on aborting volume detach for "
                                      "node %(node)s: %(err)s.")
                    else:
                        msg_format = ("Error detaching volume for "
                                      "node %(node)s: %(err)s.")
                    msg = (msg_format) % {'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)

        # 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.

        :param task: The task object.
        :returns: True if the deployment write-out process should be
                  executed.
        """
        # NOTE(TheJulia): There is no reason to check if a root volume
        # exists here because if the validation has already been passed
        # then we know that there should be a volume. If there is an
        # image_source, then we should expect to write it out.
        instance_info = task.node.instance_info
        if 'image_source' not in instance_info:
            for volume in task.volume_targets:
                if volume['boot_index'] == 0:
                    return False
        return True

    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