summaryrefslogtreecommitdiff
path: root/ironic/drivers/modules/redfish/utils.py
blob: e85e2ec6a676738a0fd89201762602367d75890a (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
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# Copyright 2017 Red Hat, Inc.
# All Rights Reserved.
# Copyright (c) 2020-2021 Dell Inc. or its subsidiaries.
#
#    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 collections
import hashlib
import os
from urllib import parse as urlparse

from oslo_log import log
from oslo_utils import excutils
from oslo_utils import importutils
from oslo_utils import strutils
import rfc3986
import tenacity

from ironic.common import exception
from ironic.common.i18n import _
from ironic.conf import CONF

sushy = importutils.try_import('sushy')

LOG = log.getLogger(__name__)

REQUIRED_PROPERTIES = {
    'redfish_address': _('The URL address to the Redfish controller. It '
                         'must include the authority portion of the URL. '
                         'If the scheme is missing, https is assumed. '
                         'For example: https://mgmt.vendor.com. '
                         'If a path is added, it will be used as the API '
                         'endpoint root_prefix. Required'),
}

OPTIONAL_PROPERTIES = {
    'redfish_system_id': _('The canonical path to the ComputerSystem '
                           'resource that the driver will interact with. '
                           'It should include the root service, version and '
                           'the unique resource path to a ComputerSystem '
                           'within the same authority as the redfish_address '
                           'property. For example: /redfish/v1/Systems/1. '
                           'This property is only required if target BMC '
                           'manages more than one ComputerSystem. Otherwise '
                           'ironic will pick the only available '
                           'ComputerSystem automatically.'),
    'redfish_username': _('User account with admin/server-profile access '
                          'privilege. Although this property is not '
                          'mandatory it\'s highly recommended to set a '
                          'username. Optional'),
    'redfish_password': _('User account password. Although this property is '
                          'not mandatory, it\'s highly recommended to set a '
                          'password. Optional'),
    'redfish_verify_ca': _('Either a Boolean value, a path to a CA_BUNDLE '
                           'file or directory with certificates of trusted '
                           'CAs. If set to True the driver will verify the '
                           'host certificates; if False the driver will '
                           'ignore verifying the SSL certificate. If it\'s '
                           'a path the driver will use the specified '
                           'certificate or one of the certificates in the '
                           'directory. Defaults to True. Optional'),
    'redfish_auth_type': _('Redfish HTTP client authentication method. Can be '
                           '"basic", "session" or "auto". If not set, the '
                           'default value is taken from Ironic '
                           'configuration as ``[redfish]auth_type`` option.')
}

COMMON_PROPERTIES = REQUIRED_PROPERTIES.copy()
COMMON_PROPERTIES.update(OPTIONAL_PROPERTIES)


def parse_driver_info(node):
    """Parse the information required for Ironic to connect to Redfish.

    :param node: an Ironic node object
    :returns: dictionary of parameters
    :raises: InvalidParameterValue on malformed parameter(s)
    :raises: MissingParameterValue on missing parameter(s)
    """
    driver_info = node.driver_info or {}
    missing_info = [key for key in REQUIRED_PROPERTIES
                    if not driver_info.get(key)]
    if missing_info:
        raise exception.MissingParameterValue(_(
            'Missing the following Redfish properties in node '
            '%(node)s driver_info: %(info)s') % {'node': node.uuid,
                                                 'info': missing_info})

    # Validate the Redfish address
    address = driver_info['redfish_address']
    try:
        parsed = rfc3986.uri_reference(address)
    except TypeError:
        raise exception.InvalidParameterValue(
            _('Invalid Redfish address %(address)s set in '
              'driver_info/redfish_address on node %(node)s') %
            {'address': address, 'node': node.uuid})

    if not parsed.scheme or not parsed.authority:
        address = 'https://%s' % address
        parsed = rfc3986.uri_reference(address)
    validator = rfc3986.validators.Validator().require_presence_of(
        'scheme', 'host',
    ).check_validity_of(
        'scheme', 'userinfo', 'host', 'path', 'query', 'fragment',
    )
    try:
        validator.validate(parsed)
    except rfc3986.exceptions.RFC3986Exception:
        raise exception.InvalidParameterValue(
            _('Invalid Redfish address %(address)s set in '
              'driver_info/redfish_address on node %(node)s') %
            {'address': address, 'node': node.uuid})
    address = '{}://{}'.format(parsed.scheme, parsed.authority)

    # Obtain the Redfish root prefix from the address path
    # If not specified, default to '/redfish/v1/'
    root_prefix = parsed.path

    redfish_system_id = driver_info.get('redfish_system_id')
    if redfish_system_id is not None:
        try:
            redfish_system_id = urlparse.quote(redfish_system_id)
        except (TypeError, AttributeError):
            raise exception.InvalidParameterValue(
                _('Invalid value "%(value)s" set in '
                  'driver_info/redfish_system_id on node %(node)s. '
                  'The value should be a path (string) to the resource '
                  'that the driver will interact with. For example: '
                  '/redfish/v1/Systems/1') %
                {'value': driver_info['redfish_system_id'], 'node': node.uuid})

    # Check if verify_ca is a Boolean or a file/directory in the file-system
    verify_ca = driver_info.get('redfish_verify_ca', True)
    if isinstance(verify_ca, str):
        if os.path.isdir(verify_ca) or os.path.isfile(verify_ca):
            pass
        else:
            try:
                verify_ca = strutils.bool_from_string(verify_ca, strict=True)
            except ValueError:
                raise exception.InvalidParameterValue(
                    _('Invalid value type set in driver_info/'
                      'redfish_verify_ca on node %(node)s. '
                      'The value should be a Boolean or the path '
                      'to a file/directory, not "%(value)s"'
                      ) % {'value': verify_ca, 'node': node.uuid})
    elif isinstance(verify_ca, bool):
        # If it's a boolean it's grand, we don't need to do anything
        pass
    else:
        raise exception.InvalidParameterValue(
            _('Invalid value type set in driver_info/redfish_verify_ca '
              'on node %(node)s. The value should be a Boolean or the path '
              'to a file/directory, not "%(value)s"') % {'value': verify_ca,
                                                         'node': node.uuid})

    auth_type = driver_info.get('redfish_auth_type', CONF.redfish.auth_type)
    if auth_type not in ('basic', 'session', 'auto'):
        raise exception.InvalidParameterValue(
            _('Invalid value "%(value)s" set in '
              'driver_info/redfish_auth_type on node %(node)s. '
              'The value should be one of "basic", "session" or "auto".') %
            {'value': auth_type, 'node': node.uuid})

    sushy_params = {'address': address,
                    'system_id': redfish_system_id,
                    'username': driver_info.get('redfish_username'),
                    'password': driver_info.get('redfish_password'),
                    'verify_ca': verify_ca,
                    'auth_type': auth_type,
                    'node_uuid': node.uuid}
    if root_prefix:
        sushy_params['root_prefix'] = root_prefix

    return sushy_params


class SessionCache(object):
    """Cache of HTTP sessions credentials"""
    AUTH_CLASSES = {}
    if sushy:
        AUTH_CLASSES.update(
            basic=sushy.auth.BasicAuth,
            session=sushy.auth.SessionAuth,
            auto=sushy.auth.SessionOrBasicAuth
        )

    _sessions = collections.OrderedDict()

    def __init__(self, driver_info):
        # Hash the password in the data structure, so we can
        # include it in the session key.
        # NOTE(TheJulia): Multiplying the address by 4, to ensure
        # we meet a minimum of 16 bytes for salt.
        pw_hash = hashlib.pbkdf2_hmac(
            'sha512',
            driver_info.get('password').encode('utf-8'),
            str(driver_info.get('address') * 4).encode('utf-8'), 40)
        self._driver_info = driver_info
        # Assemble the session key and append the hashed password to it,
        # which forces new sessions to be established when the saved password
        # is changed, just like the username, or address.
        self._session_key = tuple(
            self._driver_info.get(key)
            for key in ('address', 'username', 'verify_ca')
        ) + (pw_hash.hex(),)

    def __enter__(self):
        try:
            return self.__class__._sessions[self._session_key]
        except KeyError:
            LOG.debug('A cached redfish session for Redfish endpoint '
                      '%(endpoint)s was not detected, initiating a session.',
                      {'endpoint': self._driver_info['address']})

        auth_type = self._driver_info['auth_type']

        auth_class = self.AUTH_CLASSES[auth_type]

        authenticator = auth_class(
            username=self._driver_info['username'],
            password=self._driver_info['password']
        )

        sushy_params = {'verify': self._driver_info['verify_ca'],
                        'auth': authenticator}
        if 'root_prefix' in self._driver_info:
            sushy_params['root_prefix'] = self._driver_info['root_prefix']
        conn = sushy.Sushy(
            self._driver_info['address'],
            **sushy_params
        )

        if CONF.redfish.connection_cache_size:
            self.__class__._sessions[self._session_key] = conn
            # Save a secure hash of the password into memory, so if we
            # observe it change, we can detect the session is no longer valid.

            if (len(self.__class__._sessions)
                    > CONF.redfish.connection_cache_size):
                self._expire_oldest_session()

        return conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        # NOTE(etingof): perhaps this session token is no good
        if isinstance(exc_val, sushy.exceptions.ConnectionError):
            self.__class__._sessions.pop(self._session_key, None)
        # NOTE(TheJulia): A hard access error has surfaced, we
        # likely need to eliminate the session.
        if isinstance(exc_val, sushy.exceptions.AccessError):
            self.__class__._sessions.pop(self._session_key, None)
        # NOTE(TheJulia): Something very bad has happened, such
        # as the session is out of date, and refresh of the SessionService
        # failed resulting in an AttributeError surfacing.
        # https://storyboard.openstack.org/#!/story/2009719
        if isinstance(exc_val, AttributeError):
            self.__class__._sessions.pop(self._session_key, None)

    @classmethod
    def _expire_oldest_session(cls):
        """Expire oldest session"""
        session_keys = list(cls._sessions)
        session_key = next(iter(session_keys))
        # NOTE(etingof): GC should cause sushy to HTTP DELETE session
        # at BMC. Trouble is that contemporary sushy (1.6.0) does
        # does not do that.
        cls._sessions.pop(session_key, None)


def get_update_service(node):
    """Get a node's update service.

    :param node: an Ironic node object
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError when the UpdateService is not registered in Redfish
    """

    try:
        return _get_connection(node, lambda conn: conn.get_update_service())
    except sushy.exceptions.MissingAttributeError as e:
        LOG.error('The Redfish UpdateService was not found for '
                  'node %(node)s. Error %(error)s',
                  {'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_event_service(node):
    """Get a node's event service.

    :param node: an Ironic node object.
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError when the EventService is not registered in Redfish
    """

    try:
        return _get_connection(node, lambda conn: conn.get_event_service())
    except sushy.exceptions.MissingAttributeError as e:
        LOG.error('The Redfish EventService was not found for '
                  'node %(node)s. Error %(error)s',
                  {'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_system(node):
    """Get a Redfish System that represents a node.

    :param node: an Ironic node object
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError if the System is not registered in Redfish
    """
    driver_info = parse_driver_info(node)
    system_id = driver_info['system_id']

    try:
        return _get_connection(
            node,
            lambda conn, system_id: conn.get_system(system_id),
            system_id)
    except sushy.exceptions.ResourceNotFoundError as e:
        LOG.error('The Redfish System "%(system)s" was not found for '
                  'node %(node)s. Error %(error)s',
                  {'system': system_id or '<default>',
                   'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def get_task_monitor(node, uri):
    """Get a TaskMonitor for a node.

    :param node: an Ironic node object
    :param uri: the URI of a TaskMonitor
    :raises: RedfishConnectionError when it fails to connect to Redfish
    :raises: RedfishError when the TaskMonitor is not available in Redfish
    """

    try:
        return _get_connection(node, lambda conn: conn.get_task_monitor(uri))
    except sushy.exceptions.ResourceNotFoundError as e:
        LOG.error('The Redfish TaskMonitor "%(uri)s" was not found for '
                  'node %(node)s. Error %(error)s',
                  {'uri': uri, 'node': node.uuid, 'error': e})
        raise exception.RedfishError(error=e)


def _get_connection(node, lambda_fun, *args):
    """Get a Redfish connection to a node.

    This method gets a Redfish connection to a node by calling the passed
    lambda function, and returns the sushy object returned by the function.

    :param node: an Ironic node object
    :param lambda_fun: the function to call to retrieve the desired sushy
                       object
    :param args: the arguments to pass to the function
    :returns: the sushy object returned by the lambda function
    :raises: RedfishConnectionError when it fails to connect to Redfish
    """
    driver_info = parse_driver_info(node)

    @tenacity.retry(
        retry=tenacity.retry_if_exception_type(
            exception.RedfishConnectionError),
        stop=tenacity.stop_after_attempt(CONF.redfish.connection_attempts),
        wait=tenacity.wait_fixed(CONF.redfish.connection_retry_interval),
        reraise=True)
    def _get_cached_connection(lambda_fun, *args):
        try:
            with SessionCache(driver_info) as conn:
                return lambda_fun(conn, *args)

        # TODO(lucasagomes): We should look at other types of
        # ConnectionError such as AuthenticationError or SSLError and stop
        # retrying on them
        except sushy.exceptions.ConnectionError as e:
            LOG.warning('For node %(node)s, got a connection error from '
                        'Redfish at address "%(address)s" using auth type '
                        '"%(auth_type)s". Error: %(error)s',
                        {'address': driver_info['address'],
                         'auth_type': driver_info['auth_type'],
                         'node': node.uuid, 'error': e})
            raise exception.RedfishConnectionError(node=node.uuid, error=e)
        except sushy.exceptions.AccessError as e:
            LOG.warning('For node %(node)s, we received an authentication '
                        'access error from address %(address)s with auth_type '
                        '%(auth_type)s. The client will not be re-used upon '
                        'the next re-attempt. Please ensure your using the '
                        'correct credentials. Error: %(error)s',
                        {'address': driver_info['address'],
                         'auth_type': driver_info['auth_type'],
                         'node': node.uuid, 'error': e})
            raise exception.RedfishError(node=node.uuid, error=e)
        except AttributeError as e:
            LOG.warning('For node %(node)s, we received at AttributeError '
                        'when attempting to utilize the client. A new '
                        'client session shall be used upon the next attempt.'
                        'Attribute Error: %(error)s',
                        {'node': node.uuid, 'error': e})
            raise exception.RedfishError(node=node.uuid, error=e)

    try:
        return _get_cached_connection(lambda_fun, *args)
    except exception.RedfishConnectionError as e:
        with excutils.save_and_reraise_exception():
            LOG.error('Failed to connect to Redfish at %(address)s for '
                      'node %(node)s. Error: %(error)s',
                      {'address': driver_info['address'],
                       'node': node.uuid, 'error': e})


def get_enabled_macs(task, system):
    """Get information on MAC addresses of enabled ports using Redfish.

    :param task: a TaskManager instance containing the node to act on.
    :param system: a Redfish System object
    :returns: a dictionary containing MAC addresses of enabled interfaces
        in a {'mac': <state>} format, where <state> is a sushy constant
    """

    enabled_macs = {}
    if (system.ethernet_interfaces
            and system.ethernet_interfaces.summary):
        macs = system.ethernet_interfaces.summary

        # Identify ports for the NICs being in 'enabled' state
        for nic_mac, nic_state in macs.items():
            if nic_state != sushy.STATE_ENABLED:
                continue
            elif not nic_mac:
                LOG.warning("Ignoring device for %(node)s as no MAC "
                            "reported", {'node': task.node.uuid})
                continue
            enabled_macs[nic_mac] = nic_state

    if not enabled_macs:
        LOG.debug("No ethernet interface information is available "
                  "for node %(node)s", {'node': task.node.uuid})
    return enabled_macs


def wait_until_get_system_ready(node):
    """Wait until Redfish system is ready.

    :param node: an Ironic node object
    :raises: RedfishConnectionError on time out.
    """
    @tenacity.retry(
        retry=tenacity.retry_if_exception_type(
            exception.RedfishConnectionError),
        stop=tenacity.stop_after_attempt(CONF.redfish.connection_attempts),
        wait=tenacity.wait_fixed(CONF.redfish.connection_retry_interval),
        reraise=True)
    def _get_system(driver_info, system_id):
        try:
            with SessionCache(driver_info) as conn:
                return conn.get_system(system_id)
        except sushy.exceptions.BadRequestError as e:
            err_msg = ("System is not ready for node %(node)s, with error"
                       "%(error)s, so retrying it",
                       {'node': node.uuid, 'error': e})
            LOG.warning(err_msg)
            raise exception.RedfishConnectionError(node=node.uuid, error=e)
    driver_info = parse_driver_info(node)
    system_id = driver_info['system_id']
    return _get_system(driver_info, system_id)