Source code for ironic.drivers.modules.redfish.utils

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


[docs]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
[docs]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)
[docs]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)
[docs]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)
[docs]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)
[docs]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})
[docs]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
[docs]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)