525 lines
22 KiB
Python
525 lines
22 KiB
Python
# 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.common import utils
|
|
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)
|
|
|
|
|
|
# All available FIRMWARE COMPONENTS
|
|
BIOS = 'bios'
|
|
"BIOS Firmware Component"
|
|
|
|
BMC = 'bmc'
|
|
"BMC Firmware Component"
|
|
|
|
FIRMWARE_COMPONENTS = [BIOS, BMC]
|
|
"""Firmware Components available to update"""
|
|
|
|
|
|
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 = utils.wrap_ipv6(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)
|
|
|
|
|
|
def get_manager(node, system, manager_id=None):
|
|
"""Get a node's manager.
|
|
|
|
:param system: a Sushy system object
|
|
:param manager_id: the id of the manager
|
|
:return: a sushy Manager
|
|
:raises: RedfishError when the System doesn't have Managers associated
|
|
"""
|
|
|
|
try:
|
|
sushy_manager = None
|
|
available_managers = system.managers
|
|
if available_managers:
|
|
if manager_id is None:
|
|
sushy_manager = available_managers[0]
|
|
else:
|
|
for manager in available_managers:
|
|
if manager.identity == manager_id:
|
|
sushy_manager = manager
|
|
if sushy_manager is None:
|
|
raise Exception("Couldn't find any Sushy Manager")
|
|
return sushy_manager
|
|
except sushy.exceptions.MissingAttributeError as e:
|
|
LOG.error('Redfish Managers for node %(node)s are not associated '
|
|
'with system %(system)s. Error %(error)s',
|
|
{'system': system.identity,
|
|
'node': node.uuid, 'error': e})
|
|
raise exception.RedfishError(error=e)
|
|
except Exception as exc:
|
|
LOG.error('Redfish Manager was not found for '
|
|
'node %(node)s under system %(system)s. Error %(error)s',
|
|
{'system': system.identity,
|
|
'node': node.uuid, 'error': exc})
|
|
raise exception.RedfishError(error=exc)
|