diff options
31 files changed, 1152 insertions, 90 deletions
diff --git a/doc/source/admin/anaconda-deploy-interface.rst b/doc/source/admin/anaconda-deploy-interface.rst index 2c686506a..f48926668 100644 --- a/doc/source/admin/anaconda-deploy-interface.rst +++ b/doc/source/admin/anaconda-deploy-interface.rst @@ -277,5 +277,10 @@ Limitations This deploy interface has only been tested with Red Hat based operating systems that use anaconda. Other systems are not supported. +Runtime TLS certifiate injection into ramdisks is not supported. Assets such +as ``ramdisk`` or a ``stage2`` ramdisk image need to have trusted Certificate +Authority certificates present within the images *or* the Ironic API endpoint +utilized should utilize a known trusted Certificate Authority. + .. _`anaconda`: https://fedoraproject.org/wiki/Anaconda .. _`ks.cfg.template`: https://opendev.org/openstack/ironic/src/branch/master/ironic/drivers/modules/ks.cfg.template diff --git a/doc/source/admin/drivers/ilo.rst b/doc/source/admin/drivers/ilo.rst index f764a6d89..65ff4f6da 100644 --- a/doc/source/admin/drivers/ilo.rst +++ b/doc/source/admin/drivers/ilo.rst @@ -55,6 +55,8 @@ The hardware type ``ilo`` supports following HPE server features: * `Updating security parameters as manual clean step`_ * `Update Minimum Password Length security parameter as manual clean step`_ * `Update Authentication Failure Logging security parameter as manual clean step`_ +* `Create Certificate Signing Request(CSR) as manual clean step`_ +* `Add HTTPS Certificate as manual clean step`_ * `Activating iLO Advanced license as manual clean step`_ * `Removing CA certificates from iLO as manual clean step`_ * `Firmware based UEFI iSCSI boot from volume support`_ @@ -65,6 +67,7 @@ The hardware type ``ilo`` supports following HPE server features: * `BIOS configuration support`_ * `IPv6 support`_ * `Layer 3 or DHCP-less ramdisk booting`_ +* `Events subscription`_ Apart from above features hardware type ``ilo5`` also supports following features: @@ -200,6 +203,18 @@ The ``ilo`` hardware type supports following hardware interfaces: enabled_hardware_types = ilo enabled_rescue_interfaces = agent,no-rescue +* vendor + Supports ``ilo``, ``ilo-redfish`` and ``no-vendor``. The default is + ``ilo``. They can be enabled by using the + ``[DEFAULT]enabled_vendor_interfaces`` option in ``ironic.conf`` as given + below: + + .. code-block:: ini + + [DEFAULT] + enabled_hardware_types = ilo + enabled_vendor_interfaces = ilo,ilo-redfish,no-vendor + The ``ilo5`` hardware type supports all the ``ilo`` interfaces described above, except for ``boot`` and ``raid`` interfaces. The details of ``boot`` and @@ -751,6 +766,12 @@ Supported **Manual** Cleaning Operations ``update_auth_failure_logging_threshold``: Updates the Authentication Failure Logging security parameter. See `Update Authentication Failure Logging security parameter as manual clean step`_ for user guidance on usage. + ``create_csr``: + Creates the certificate signing request. See `Create Certificate Signing Request(CSR) as manual clean step`_ + for user guidance on usage. + ``add_https_certificate``: + Adds the signed HTTPS certificate to the iLO. See `Add HTTPS Certificate as manual clean step`_ for user + guidance on usage. * iLO with firmware version 1.5 is minimally required to support all the operations. @@ -1648,6 +1669,54 @@ Both the arguments ``logging_threshold`` and ``ignore`` are optional. The accept value be False. If user passes the value of logging_threshold as 0, the Authentication Failure Logging security parameter will be disabled. +Create Certificate Signing Request(CSR) as manual clean step +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +iLO driver can invoke ``create_csr`` request as a manual clean step. This step is only supported for iLO5 based hardware. + +An example of a manual clean step with ``create_csr`` as the only clean step could be:: + + "clean_steps": [{ + "interface": "management", + "step": "create_csr", + "args": { + "csr_params": { + "City": "Bengaluru", + "CommonName": "1.1.1.1", + "Country": "India", + "OrgName": "HPE", + "State": "Karnataka" + } + } + }] + +The ``[ilo]cert_path`` option in ``ironic.conf`` is used as the directory path for +creating the CSR, which defaults to ``/var/lib/ironic/ilo``. The CSR is created in the directory location +given in ``[ilo]cert_path`` in ``node_uuid`` directory as <node_uuid>.csr. + + +Add HTTPS Certificate as manual clean step +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +iLO driver can invoke ``add_https_certificate`` request as a manual clean step. This step is only supported for +iLO5 based hardware. + +An example of a manual clean step with ``add_https_certificate`` as the only clean step could be:: + + "clean_steps": [{ + "interface": "management", + "step": "add_https_certificate", + "args": { + "cert_file": "/test1/iLO.crt" + } + }] + +Argument ``cert_file`` is mandatory. The ``cert_file`` takes the path or url of the certificate file. +The url schemes supported are: ``file``, ``http`` and ``https``. +The CSR generated in step ``create_csr`` needs to be signed by a valid CA and the resultant HTTPS certificate should +be provided in ``cert_file``. It copies the ``cert_file`` to ``[ilo]cert_path`` under ``node.uuid`` as <node_uuid>.crt +before adding it to iLO. + RAID Support ^^^^^^^^^^^^ @@ -2136,6 +2205,12 @@ DHCP-less deploy is supported by ``ilo`` and ``ilo5`` hardware types. However it would work only with ilo-virtual-media boot interface. See :doc:`/admin/dhcp-less` for more information. +Events subscription +^^^^^^^^^^^^^^^^^^^ +Events subscription is supported by ``ilo`` and ``ilo5`` hardware types with +``ilo`` vendor interface for Gen10 and Gen10 Plus servers. See +:ref:`node-vendor-passthru-methods` for more information. + .. _`ssacli documentation`: https://support.hpe.com/hpsc/doc/public/display?docId=c03909334 .. _`proliant-tools`: https://docs.openstack.org/diskimage-builder/latest/elements/proliant-tools/README.html .. _`HPE iLO4 User Guide`: https://h20566.www2.hpe.com/hpsc/doc/public/display?docId=c03334051 diff --git a/doc/source/admin/drivers/redfish.rst b/doc/source/admin/drivers/redfish.rst index dd19f8bde..eb1f561f4 100644 --- a/doc/source/admin/drivers/redfish.rst +++ b/doc/source/admin/drivers/redfish.rst @@ -87,8 +87,18 @@ field: The "auto" mode first tries "session" and falls back to "basic" if session authentication is not supported by the Redfish BMC. Default is set in ironic config - as ``[redfish]auth_type``. + as ``[redfish]auth_type``. Most operators should not + need to leverage this setting. Session based + authentication should generally be used in most + cases as it prevents re-authentication every time + a background task checks in with the BMC. +.. note:: + The ``redfish_address``, ``redfish_username``, ``redfish_password``, + and ``redfish_verify_ca`` fields, if changed, will trigger a new session + to be establsihed and cached with the BMC. The ``redfish_auth_type`` field + will only be used for the creation of a new cached session, or should + one be rejected by the BMC. The ``baremetal node create`` command can be used to enroll a node with the ``redfish`` driver. For example: @@ -533,6 +543,8 @@ settings. The following fields will be returned in the BIOS API "``unique``", "The setting is specific to this node" "``reset_required``", "After changing this setting a node reboot is required" +.. _node-vendor-passthru-methods: + Node Vendor Passthru Methods ============================ @@ -620,6 +632,44 @@ Eject Virtual Media "boot_device (optional)", "body", "string", "Type of the device to eject (all devices by default)" +Internal Session Cache +====================== + +The ``redfish`` hardware type, and derived interfaces, utilizes a built-in +session cache which prevents Ironic from re-authenticating every time +Ironic attempts to connect to the BMC for any reason. + +This consists of cached connectors objects which are used and tracked by +a unique consideration of ``redfish_username``, ``redfish_password``, +``redfish_verify_ca``, and finally ``redfish_address``. Changing any one +of those values will trigger a new session to be created. +The ``redfish_system_id`` value is explicitly not considered as Redfish +has a model of use of one BMC to many systems, which is also a model +Ironic supports. + +The session cache default size is ``1000`` sessions per conductor. +If you are operating a deployment with a larger number of Redfish +BMCs, it is advised that you do appropriately tune that number. +This can be tuned via the API service configuration file, +``[redfish]connection_cache_size``. + +Session Cache Expiration +~~~~~~~~~~~~~~~~~~~~~~~~ + +By default, sessions remain cached for as long as possible in +memory, as long as they have not experienced an authentication, +connection, or other unexplained error. + +Under normal circumstances, the sessions will only be rolled out +of the cache in order of oldest first when the cache becomes full. +There is no time based expiration to entries in the session cache. + +Of course, the cache is only in memory, and restarting the +``ironic-conductor`` will also cause the cache to be rebuilt +from scratch. If this is due to any persistent connectivity issue, +this may be sign of an unexpected condition, and please consider +contacting the Ironic developer community for assistance. + .. _Redfish: http://redfish.dmtf.org/ .. _Sushy: https://opendev.org/openstack/sushy .. _TLS: https://en.wikipedia.org/wiki/Transport_Layer_Security diff --git a/doc/source/admin/drivers/snmp.rst b/doc/source/admin/drivers/snmp.rst index 1c402ab9b..eed4ed794 100644 --- a/doc/source/admin/drivers/snmp.rst +++ b/doc/source/admin/drivers/snmp.rst @@ -22,39 +22,47 @@ this table could possibly work using a similar driver. Please report any device status. -============== ========== ========== ===================== -Manufacturer Model Supported? Driver name -============== ========== ========== ===================== -APC AP7920 Yes apc_masterswitch -APC AP9606 Yes apc_masterswitch -APC AP9225 Yes apc_masterswitchplus -APC AP7155 Yes apc_rackpdu -APC AP7900 Yes apc_rackpdu -APC AP7901 Yes apc_rackpdu -APC AP7902 Yes apc_rackpdu -APC AP7911a Yes apc_rackpdu -APC AP7921 Yes apc_rackpdu -APC AP7922 Yes apc_rackpdu -APC AP7930 Yes apc_rackpdu -APC AP7931 Yes apc_rackpdu -APC AP7932 Yes apc_rackpdu -APC AP7940 Yes apc_rackpdu -APC AP7941 Yes apc_rackpdu -APC AP7951 Yes apc_rackpdu -APC AP7960 Yes apc_rackpdu -APC AP7990 Yes apc_rackpdu -APC AP7998 Yes apc_rackpdu -APC AP8941 Yes apc_rackpdu -APC AP8953 Yes apc_rackpdu -APC AP8959 Yes apc_rackpdu -APC AP8961 Yes apc_rackpdu -APC AP8965 Yes apc_rackpdu -Aten all? Yes aten -CyberPower all? Untested cyberpower -EatonPower all? Untested eatonpower -Teltronix all? Yes teltronix -BayTech MRP27 Yes baytech_mrp27 -============== ========== ========== ===================== +============== ============== ========== ===================== +Manufacturer Model Supported? Driver name +============== ============== ========== ===================== +APC AP7920 Yes apc_masterswitch +APC AP9606 Yes apc_masterswitch +APC AP9225 Yes apc_masterswitchplus +APC AP7155 Yes apc_rackpdu +APC AP7900 Yes apc_rackpdu +APC AP7901 Yes apc_rackpdu +APC AP7902 Yes apc_rackpdu +APC AP7911a Yes apc_rackpdu +APC AP7921 Yes apc_rackpdu +APC AP7922 Yes apc_rackpdu +APC AP7930 Yes apc_rackpdu +APC AP7931 Yes apc_rackpdu +APC AP7932 Yes apc_rackpdu +APC AP7940 Yes apc_rackpdu +APC AP7941 Yes apc_rackpdu +APC AP7951 Yes apc_rackpdu +APC AP7960 Yes apc_rackpdu +APC AP7990 Yes apc_rackpdu +APC AP7998 Yes apc_rackpdu +APC AP8941 Yes apc_rackpdu +APC AP8953 Yes apc_rackpdu +APC AP8959 Yes apc_rackpdu +APC AP8961 Yes apc_rackpdu +APC AP8965 Yes apc_rackpdu +Aten all? Yes aten +CyberPower all? Untested cyberpower +EatonPower all? Untested eatonpower +Teltronix all? Yes teltronix +BayTech MRP27 Yes baytech_mrp27 +Raritan PX3-5547V-V2 Yes raritan_pdu2 +Raritan PX3-5726V Yes raritan_pdu2 +Raritan PX3-5776U-N2 Yes raritan_pdu2 +Raritan PX3-5969U-V2 Yes raritan_pdu2 +Raritan PX3-5961I2U-V2 Yes raritan_pdu2 +Vertiv NU30212 Yes vertivgeist_pdu +ServerTech CW-16VE-P32M Yes servertech_sentry3 +ServerTech C2WG24SN Yes servertech_sentry4 +============== ============== ========== ===================== Software Requirements diff --git a/driver-requirements.txt b/driver-requirements.txt index bc12a07bb..3725c3ddf 100644 --- a/driver-requirements.txt +++ b/driver-requirements.txt @@ -4,7 +4,7 @@ # python projects they should package as optional dependencies for Ironic. # These are available on pypi -proliantutils>=2.13.0 +proliantutils>=2.14.0 pysnmp>=4.3.0,<5.0.0 python-scciclient>=0.12.2 python-dracclient>=5.1.0,<9.0.0 diff --git a/ironic/common/pxe_utils.py b/ironic/common/pxe_utils.py index 1849aaa7d..ec0719b75 100644 --- a/ironic/common/pxe_utils.py +++ b/ironic/common/pxe_utils.py @@ -1004,6 +1004,8 @@ def build_kickstart_config_options(task): if node.driver_internal_info.get('is_source_a_path', False): # Record a value so it matches as the template opts in. params['is_source_a_path'] = 'true' + if CONF.anaconda.insecure_heartbeat: + params['insecure_heartbeat'] = 'true' params['agent_token'] = node.driver_internal_info['agent_secret_token'] heartbeat_url = '%s/v1/heartbeat/%s' % ( deploy_utils.get_ironic_api_url().rstrip('/'), diff --git a/ironic/conf/anaconda.py b/ironic/conf/anaconda.py index 8ae3ab533..4f230ecdc 100644 --- a/ironic/conf/anaconda.py +++ b/ironic/conf/anaconda.py @@ -28,6 +28,17 @@ opts = [ help=_('kickstart template to use when no kickstart template ' 'is specified in the instance_info or the glance OS ' 'image.')), + cfg.BoolOpt('insecure_heartbeat', + default=False, + mutable=True, + help=_('Option to allow the kickstart configuration to be ' + 'informed if SSL/TLS certificate verificaiton should ' + 'be enforced, or not. This option exists largely to ' + 'facilitate easy testing and use of the ``anaconda`` ' + 'deployment interface. When this option is set, ' + 'heartbeat operations, depending on the contents of ' + 'the utilized kickstart template, may not enfore TLS ' + 'certificate verification.')), ] diff --git a/ironic/conf/deploy.py b/ironic/conf/deploy.py index 6ae080c83..198e0ac95 100644 --- a/ironic/conf/deploy.py +++ b/ironic/conf/deploy.py @@ -133,9 +133,7 @@ opts = [ 'to set an explicit value for this option, and if the ' 'setting or default differs from nodes, to ensure that ' 'nodes are configured specifically for their desired ' - 'boot mode. This option ' - 'only has effect when management interface supports ' - 'boot mode management') % { + 'boot mode.') % { 'bios': boot_modes.LEGACY_BIOS, 'uefi': boot_modes.UEFI}), cfg.BoolOpt('configdrive_use_object_store', diff --git a/ironic/conf/ilo.py b/ironic/conf/ilo.py index 364c64c81..197378ce7 100644 --- a/ironic/conf/ilo.py +++ b/ironic/conf/ilo.py @@ -120,6 +120,11 @@ opts = [ '/proc/cmdline. Mind severe cmdline size limit! Can be ' 'overridden by `instance_info/kernel_append_params` ' 'property.')), + cfg.StrOpt('cert_path', + default='/var/lib/ironic/ilo/', + help=_('On the ironic-conductor node, directory where ilo ' + 'driver stores the CSR and the cert.')), + ] diff --git a/ironic/db/sqlalchemy/__init__.py b/ironic/db/sqlalchemy/__init__.py index 0f792361a..88ac079d0 100644 --- a/ironic/db/sqlalchemy/__init__.py +++ b/ironic/db/sqlalchemy/__init__.py @@ -13,4 +13,6 @@ from oslo_db.sqlalchemy import enginefacade # NOTE(dtantsur): we want sqlite as close to a real database as possible. -enginefacade.configure(sqlite_fk=True) +# FIXME(stephenfin): we need to remove reliance on autocommit semantics ASAP +# since it's not compatible with SQLAlchemy 2.0 +enginefacade.configure(sqlite_fk=True, __autocommit=True) diff --git a/ironic/drivers/ilo.py b/ironic/drivers/ilo.py index d8bbafb9e..b6e189ee9 100644 --- a/ironic/drivers/ilo.py +++ b/ironic/drivers/ilo.py @@ -1,3 +1,4 @@ +# Copyright 2022 Hewlett Packard Enterprise Development LP # Copyright 2014 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -67,7 +68,7 @@ class IloHardware(generic.GenericHardware): @property def supported_vendor_interfaces(self): - """List of supported power interfaces.""" + """List of supported vendor interfaces.""" return [vendor.VendorPassthru, noop.NoVendor] diff --git a/ironic/drivers/modules/ilo/common.py b/ironic/drivers/modules/ilo/common.py index 2b5b8c0db..13f975c67 100644 --- a/ironic/drivers/modules/ilo/common.py +++ b/ironic/drivers/modules/ilo/common.py @@ -1,3 +1,4 @@ +# Copyright 2022 Hewlett Packard Enterprise Development LP # Copyright 2014 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -31,6 +32,7 @@ from ironic.common import boot_devices from ironic.common import exception from ironic.common.glance_service import service_utils from ironic.common.i18n import _ +from ironic.common import image_service from ironic.common import images from ironic.common import swift from ironic.common import utils @@ -494,6 +496,26 @@ def update_ipmi_properties(task): task.node.driver_info = info +def update_redfish_properties(task): + """Update redfish properties to node driver_info + + This method updates the node's driver info with redfish driver driver_info. + :param task: a task from TaskManager. + """ + node = task.node + info = node.driver_info + + # updating redfish credentials + info['redfish_address'] = info.get('ilo_address') + info['redfish_username'] = info.get('ilo_username') + info['redfish_password'] = info.get('ilo_password') + info['redfish_verify_ca'] = info.get('ilo_verify_ca') + info['redfish_system_id'] = '/redfish/v1/Systems/1' + + # saving redfish credentials to task object + task.node.driver_info = info + + def _get_floppy_image_name(node): """Returns the floppy image name for a given node. @@ -1126,3 +1148,23 @@ def setup_uefi_https(task, iso, persistent=False): except ilo_error.IloError as ilo_exception: raise exception.IloOperationError(operation=operation, error=ilo_exception) + + +def download(target_file, file_url): + """Downloads file based on the scheme. + + It downloads the file (url) to given location. + The supported url schemes are file, http, and https. + :param target_file: target file for copying the downloaded file. + :param file_url: source file url from where file needs to be downloaded. + :raises: ImageDownloadFailed, on failure to download the file. + """ + parsed_url = urlparse.urlparse(file_url) + if parsed_url.scheme == "file": + src_file = parsed_url.path + with open(target_file, 'wb') as fd: + image_service.FileImageService().download(src_file, fd) + elif parsed_url.scheme in ('http', 'https'): + src_file = parsed_url.geturl() + with open(target_file, 'wb') as fd: + image_service.HttpImageService().download(src_file, fd) diff --git a/ironic/drivers/modules/ilo/management.py b/ironic/drivers/modules/ilo/management.py index c9a8259e6..5c4f03fb6 100644 --- a/ironic/drivers/modules/ilo/management.py +++ b/ironic/drivers/modules/ilo/management.py @@ -14,7 +14,8 @@ """ iLO Management Interface """ - +import os +import shutil from urllib import parse as urlparse from ironic_lib import metrics_utils @@ -79,6 +80,27 @@ _RESET_ILO_CREDENTIALS_ARGSINFO = { } } +_CREATE_CSR_ARGSINFO = { + 'csr_params': { + 'description': ( + "This arguments represents the information needed " + "to create the CSR certificate. The keys to be provided are " + "City, CommonName, OrgName, State." + ), + 'required': True + } +} + +_ADD_HTTPS_CERT_ARGSINFO = { + 'cert_file': { + 'description': ( + "This argument represents the path to the signed HTTPS " + "certificate which will be added to the iLO." + ), + 'required': True + } +} + _SECURITY_PARAMETER_UPDATE_ARGSINFO = { 'security_parameters': { 'description': ( @@ -574,6 +596,61 @@ class IloManagement(base.ManagementInterface): "parameter for node %(node)s is updated", {'node': node.uuid}) + @METRICS.timer('IloManagement.create_csr') + @base.clean_step(priority=0, abortable=False, + argsinfo=_CREATE_CSR_ARGSINFO) + def create_csr(self, task, **kwargs): + """Creates the CSR. + + :param task: a TaskManager object. + """ + node = task.node + csr_params = kwargs.get('csr_params') + csr_path = CONF.ilo.cert_path + path = os.path.join(csr_path, task.node.uuid) + if not os.path.exists(path): + os.makedirs(path, 0o755) + + LOG.debug("Creating CSR for node %(node)s ..", + {'node': node.uuid}) + _execute_ilo_step(node, 'create_csr', path, csr_params) + LOG.info("Creation of CSR for node %(node)s is " + "completed.", {'node': node.uuid}) + + @METRICS.timer('IloManagement.add_https_certificate') + @base.clean_step(priority=0, abortable=False, + argsinfo=_ADD_HTTPS_CERT_ARGSINFO) + def add_https_certificate(self, task, **kwargs): + """Adds the signed HTTPS certificate to the iLO. + + :param task: a TaskManager object. + """ + node = task.node + csr_path = CONF.ilo.cert_path + path = os.path.join(csr_path, task.node.uuid) + if not os.path.exists(path): + os.makedirs(path, 0o755) + cert_file_name = node.uuid + ".crt" + cert_file_path = os.path.join(path, cert_file_name) + cert_file = kwargs.get('cert_file') + url_scheme = urlparse.urlparse(cert_file).scheme + if url_scheme == '': + shutil.copy(cert_file, cert_file_path) + elif url_scheme in ('http', 'https', 'file'): + ilo_common.download(cert_file_path, cert_file) + else: + msg = (_("The url scheme %(scheme)s not supported with clean step " + "%(step)s") % {'scheme': url_scheme, + 'step': 'add_https_certificate'}) + raise exception.IloOperationNotSupported(operation='clean step', + error=msg) + + LOG.debug("Adding the signed HTTPS certificate to the " + "node %(node)s ..", {'node': node.uuid}) + _execute_ilo_step(node, 'add_https_certificate', cert_file_path) + LOG.info("Adding of HTTPS certificate to the node %(node)s " + "is completed.", {'node': node.uuid}) + @METRICS.timer('IloManagement.update_firmware') @base.deploy_step(priority=0, argsinfo=_FIRMWARE_UPDATE_ARGSINFO) @base.clean_step(priority=0, abortable=False, diff --git a/ironic/drivers/modules/ilo/vendor.py b/ironic/drivers/modules/ilo/vendor.py index 2f4986a2f..fa0400703 100644 --- a/ironic/drivers/modules/ilo/vendor.py +++ b/ironic/drivers/modules/ilo/vendor.py @@ -1,3 +1,4 @@ +# Copyright 2022 Hewlett Packard Enterprise Development LP # Copyright 2015 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -25,16 +26,14 @@ from ironic.conductor import utils as manager_utils from ironic.drivers import base from ironic.drivers.modules import deploy_utils from ironic.drivers.modules.ilo import common as ilo_common +from ironic.drivers.modules.redfish import vendor as redfish_vendor METRICS = metrics_utils.get_metrics_logger(__name__) -class VendorPassthru(base.VendorInterface): +class VendorPassthru(redfish_vendor.RedfishVendorPassthru): """Vendor-specific interfaces for iLO deploy drivers.""" - def get_properties(self): - return {} - @METRICS.timer('IloVendorPassthru.validate') def validate(self, task, method, **kwargs): """Validate vendor-specific actions. @@ -50,10 +49,26 @@ class VendorPassthru(base.VendorInterface): passed. :raises: InvalidParameterValue, if any of the parameters have invalid value. + :raises: IloOperationNotSupported, if the driver does not support the + given operation with ilo vendor interface. """ if method == 'boot_into_iso': self._validate_boot_into_iso(task, kwargs) return + redfish_event_methods = ['create_subscription', + 'delete_subscription', + 'get_all_subscriptions', 'get_subscription'] + if method in redfish_event_methods: + self._validate_is_it_a_supported_system(task) + ilo_common.parse_driver_info(task.node) + ilo_common.update_redfish_properties(task) + if method == 'eject_vmedia': + error_message = _(method + ( + " can not be performed as the driver does not support " + "eject_vmedia through ilo vendor interface")) + raise exception.IloOperationNotSupported(operation=method, + error=error_message) + super(VendorPassthru, self).validate(task, method, **kwargs) def _validate_boot_into_iso(self, task, kwargs): @@ -99,3 +114,23 @@ class VendorPassthru(base.VendorInterface): ilo_common.setup_vmedia(task, kwargs['boot_iso_href'], ramdisk_options=None) manager_utils.node_power_action(task, states.REBOOT) + + def _validate_is_it_a_supported_system(self, task): + """Verify and raise an exception if it is not a supported system. + + :param task: A TaskManager object. + :param kwargs: The arguments sent with vendor passthru. + :raises: IloOperationNotSupported, if the node is not a Gen10 or + Gen10 Plus system. + """ + + node = task.node + ilo_object = ilo_common.get_ilo_object(node) + product_name = ilo_object.get_product_name() + operation = _("Event methods") + error_message = _(operation + ( + " can not be performed as the driver does not support Event " + "methods on the given node")) + if 'Gen10' not in product_name: + raise exception.IloOperationNotSupported(operation=operation, + error=error_message) diff --git a/ironic/drivers/modules/ks.cfg.template b/ironic/drivers/modules/ks.cfg.template index ca799953a..93788fdb8 100644 --- a/ironic/drivers/modules/ks.cfg.template +++ b/ironic/drivers/modules/ks.cfg.template @@ -36,11 +36,11 @@ liveimg --url {{ ks_options.liveimg_url }} # Following %pre and %onerror sections are mandatory %pre -/usr/bin/curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'X-OpenStack-Ironic-API-Version: 1.72' -d '{"callback_url": "", "agent_token": "{{ ks_options.agent_token }}", "agent_status": "start", "agent_status_message": "Deployment starting. Running pre-installation scripts."}' {{ ks_options.heartbeat_url }} +/usr/bin/curl {% if 'insecure_heartbeat' in ks_options %}--insecure{% endif %} -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'X-OpenStack-Ironic-API-Version: 1.72' -d '{"callback_url": "", "agent_token": "{{ ks_options.agent_token }}", "agent_status": "start", "agent_status_message": "Deployment starting. Running pre-installation scripts."}' {{ ks_options.heartbeat_url }} %end %onerror -/usr/bin/curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'X-OpenStack-Ironic-API-Version: 1.72' -d '{"callback_url": "", "agent_token": "{{ ks_options.agent_token }}", "agent_status": "error", "agent_status_message": "Error: Deploying using anaconda. Check console for more information."}' {{ ks_options.heartbeat_url }} +/usr/bin/curl {% if 'insecure_heartbeat' in ks_options %}--insecure{% endif %} -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'X-OpenStack-Ironic-API-Version: 1.72' -d '{"callback_url": "", "agent_token": "{{ ks_options.agent_token }}", "agent_status": "error", "agent_status_message": "Error: Deploying using anaconda. Check console for more information."}' {{ ks_options.heartbeat_url }} %end # Config-drive information, if any. @@ -54,5 +54,5 @@ liveimg --url {{ ks_options.liveimg_url }} # before rebooting. %post sync -/usr/bin/curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'X-OpenStack-Ironic-API-Version: 1.72' -d '{"callback_url": "", "agent_token": "{{ ks_options.agent_token }}", "agent_status": "end", "agent_status_message": "Deployment completed successfully."}' {{ ks_options.heartbeat_url }} +/usr/bin/curl {% if 'insecure_heartbeat' in ks_options %}--insecure{% endif %} -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'X-OpenStack-Ironic-API-Version: 1.72' -d '{"callback_url": "", "agent_token": "{{ ks_options.agent_token }}", "agent_status": "end", "agent_status_message": "Deployment completed successfully."}' {{ ks_options.heartbeat_url }} %end diff --git a/ironic/drivers/modules/redfish/utils.py b/ironic/drivers/modules/redfish/utils.py index 40cf33bce..e85e2ec6a 100644 --- a/ironic/drivers/modules/redfish/utils.py +++ b/ironic/drivers/modules/redfish/utils.py @@ -15,6 +15,7 @@ # under the License. import collections +import hashlib import os from urllib import parse as urlparse @@ -198,43 +199,59 @@ class SessionCache(object): _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: - auth_type = self._driver_info['auth_type'] + LOG.debug('A cached redfish session for Redfish endpoint ' + '%(endpoint)s was not detected, initiating a session.', + {'endpoint': self._driver_info['address']}) - auth_class = self.AUTH_CLASSES[auth_type] + auth_type = self._driver_info['auth_type'] - authenticator = auth_class( - username=self._driver_info['username'], - password=self._driver_info['password'] - ) + auth_class = self.AUTH_CLASSES[auth_type] - 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 - ) + 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 + 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() + if (len(self.__class__._sessions) + > CONF.redfish.connection_cache_size): + self._expire_oldest_session() - return conn + return conn def __exit__(self, exc_type, exc_val, exc_tb): # NOTE(etingof): perhaps this session token is no good diff --git a/ironic/drivers/modules/snmp.py b/ironic/drivers/modules/snmp.py index 4e700c6f8..d544d5687 100644 --- a/ironic/drivers/modules/snmp.py +++ b/ironic/drivers/modules/snmp.py @@ -799,6 +799,341 @@ class SNMPDriverBaytechMRP27(SNMPDriverSimple): value_power_on = 1 +class SNMPDriverServerTechSentry3(SNMPDriverBase): + """SNMP driver class for Server Technology Sentry 3 PDUs. + + ftp://ftp.servertech.com/Pub/SNMP/sentry3/Sentry3.mib + + SNMP objects for Server Technology Power PDU. + 1.3.6.1.4.1.1718.3.2.3.1.5.1.1.<outlet ID> outletStatus + Read 0=off, 1=on, 2=off wait, 3=on wait, [...more options follow] + 1.3.6.1.4.1.1718.3.2.3.1.11.1.1.<outlet ID> outletControlAction + Write 0=no action, 1=on, 2=off, 3=reboot + """ + + oid_device = (1718, 3, 2, 3, 1) + oid_tower_infeed_idx = (1, 1, ) + oid_power_status = (5,) + oid_power_action = (11,) + + status_off = 0 + status_on = 1 + status_off_wait = 2 + status_on_wait = 3 + + value_power_on = 1 + value_power_off = 2 + + def __init__(self, *args, **kwargs): + super(SNMPDriverServerTechSentry3, self).__init__(*args, **kwargs) + # Due to its use of different OIDs for different actions, we only form + # an OID that holds the common substring of the OIDs for power + # operations. + self.oid_base = self.oid_enterprise + self.oid_device + + def _snmp_oid(self, oid): + """Return the OID for one of the outlet control objects. + + :param oid: The action-dependent portion of the OID, as a tuple of + integers. + :returns: The full OID as a tuple of integers. + """ + + outlet = self.snmp_info['outlet'] + full_oid = self.oid_base + oid + self.oid_tower_infeed_idx + (outlet,) + return full_oid + + def _snmp_power_state(self): + oid = self._snmp_oid(self.oid_power_status) + state = self.client.get(oid) + + # Translate the state to an Ironic power state. + if state in (self.status_on, self.status_off_wait): + power_state = states.POWER_ON + elif state in (self.status_off, self.status_on_wait): + power_state = states.POWER_OFF + else: + LOG.warning("SeverTech Sentry3 PDU %(addr)s oid %(oid) outlet " + "%(outlet)s: unrecognised power state %(state)s.", + {'addr': self.snmp_info['address'], + 'oid': oid, + 'outlet': self.snmp_info['outlet'], + 'state': state}) + power_state = states.ERROR + + return power_state + + def _snmp_power_on(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_on) + self.client.set(oid, value) + + def _snmp_power_off(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_off) + self.client.set(oid, value) + + +class SNMPDriverServerTechSentry4(SNMPDriverBase): + """SNMP driver class for Server Technology Sentry 4 PDUs. + + https://www.servertech.com/support/sentry-mib-oid-tree-downloads + + SNMP objects for Server Technology Power PDU. + 1.3.6.1.4.1.1718.4.1.8.5.1.1<outlet ID> outletStatus + notSet (0) fixedOn (1) idleOff (2) idleOn (3) [...more options follow] + pendOn (8) pendOff (9) off (10) on (11) [...more options follow] + eventOff (16) eventOn (17) eventReboot (18) eventShutdown (19) + 1.3.6.1.4.1.1718.4.1.8.5.1.2.<outlet ID> outletControlAction + Write 0=no action, 1=on, 2=off, 3=reboot + """ + + oid_device = (1718, 4, 1, 8, 5, 1) + oid_tower_infeed_idx = (1, 1, ) + oid_power_status = (1,) + oid_power_action = (2,) + + notSet = 0 + fixedOn = 1 + idleOff = 2 + idleOn = 3 + wakeOff = 4 + wakeOn = 5 + ocpOff = 6 + ocpOn = 7 + status_pendOn = 8 + status_pendOff = 9 + status_off = 10 + status_on = 11 + reboot = 12 + shutdown = 13 + lockedOff = 14 + lockedOn = 15 + + value_power_on = 1 + value_power_off = 2 + + def __init__(self, *args, **kwargs): + super(SNMPDriverServerTechSentry4, self).__init__(*args, **kwargs) + # Due to its use of different OIDs for different actions, we only form + # an OID that holds the common substring of the OIDs for power + # operations. + self.oid_base = self.oid_enterprise + self.oid_device + + def _snmp_oid(self, oid): + """Return the OID for one of the outlet control objects. + + :param oid: The action-dependent portion of the OID, as a tuple of + integers. + :returns: The full OID as a tuple of integers. + """ + + outlet = self.snmp_info['outlet'] + full_oid = self.oid_base + oid + self.oid_tower_infeed_idx + (outlet,) + return full_oid + + def _snmp_power_state(self): + oid = self._snmp_oid(self.oid_power_status) + state = self.client.get(oid) + + # Translate the state to an Ironic power state. + if state in (self.status_on, self.status_pendOn, self.idleOn): + power_state = states.POWER_ON + elif state in (self.status_off, self.status_pendOff): + power_state = states.POWER_OFF + else: + LOG.warning("ServerTech Sentry4 PDU %(addr)s oid %(oid)s outlet " + "%(outlet)s: unrecognised power state %(state)s.", + {'addr': self.snmp_info['address'], + 'oid': oid, + 'outlet': self.snmp_info['outlet'], + 'state': state}) + power_state = states.ERROR + + return power_state + + def _snmp_power_on(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_on) + self.client.set(oid, value) + + def _snmp_power_off(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_off) + self.client.set(oid, value) + + +class SNMPDriverRaritanPDU2(SNMPDriverBase): + """SNMP driver class for Raritan PDU2 PDUs. + + http://support.raritan.com/px2/version-2.4.1/mibs/pdu2-mib-020400-39592.txt + http://cdn.raritan.com/download/PX/v1.5.20/PDU-MIB.txt + + Command: + snmpset -v2c -c private -m+PDU2-MIB <pdu IP address> \ + PDU2-MIB::switchingOperation.1.4 = cycle + snmpset -v2c -c private <pdu IP address> \ + .1.3.6.1.4.1.13742.6.4.1.2.1.2.1.4 i 2 + Output: + PDU2-MIB::switchingOperation.1.4 = INTEGER: cycle(2) + """ + + oid_device = (13742, 6, 4, 1, 2, 1) + oid_power_action = (2, ) + oid_power_status = (3, ) + oid_tower_infeed_idx = (1, ) + + unavailable = -1 + status_open = 0 + status_closed = 1 + belowLowerCritical = 2 + belowLowerWarning = 3 + status_normal = 4 + aboveUpperWarning = 5 + aboveUpperCritical = 6 + status_on = 7 + status_off = 8 + detected = 9 + notDetected = 10 + alarmed = 11 + ok = 12 + marginal = 13 + fail = 14 + yes = 15 + no = 16 + standby = 17 + one = 18 + two = 19 + inSync = 20 + outOfSync = 21 + + value_power_on = 1 + value_power_off = 0 + + def __init__(self, *args, **kwargs): + super(SNMPDriverRaritanPDU2, self).__init__(*args, **kwargs) + # Due to its use of different OIDs for different actions, we only form + # an OID that holds the common substring of the OIDs for power + # operations. + self.oid_base = self.oid_enterprise + self.oid_device + + def _snmp_oid(self, oid): + """Return the OID for one of the outlet control objects. + + :param oid: The action-dependent portion of the OID, as a tuple of + integers. + :returns: The full OID as a tuple of integers. + """ + + outlet = self.snmp_info['outlet'] + full_oid = self.oid_base + oid + self.oid_tower_infeed_idx + (outlet,) + return full_oid + + def _snmp_power_state(self): + oid = self._snmp_oid(self.oid_power_status) + state = self.client.get(oid) + + # Translate the state to an Ironic power state. + if state == self.status_on: + power_state = states.POWER_ON + elif state == self.status_off: + power_state = states.POWER_OFF + else: + LOG.warning("Raritan PDU2 PDU %(addr)s oid %(oid)s outlet " + "%(outlet)s: unrecognised power state %(state)s.", + {'addr': self.snmp_info['address'], + 'oid': oid, + 'outlet': self.snmp_info['outlet'], + 'state': state}) + power_state = states.ERROR + + return power_state + + def _snmp_power_on(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_on) + self.client.set(oid, value) + + def _snmp_power_off(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_off) + self.client.set(oid, value) + + +class SNMPDriverVertivGeistPDU(SNMPDriverBase): + """SNMP driver class for VertivGeist NU30017L/NU30019L PDU. + + https://mibs.observium.org/mib/GEIST-V5-MIB/ + + """ + + oid_device = (21239, 5, 2, 3, 5, 1) + oid_power_action = (6, ) + oid_power_status = (4, ) + oid_tower_infeed_idx = (1, ) + + on = 1 + off = 2 + on2off = 3 + off2on = 4 + rebootOn = 5 + rebootOff = 5 + unavailable = 7 + + value_power_on = 2 + value_power_off = 4 + + def __init__(self, *args, **kwargs): + super(SNMPDriverVertivGeistPDU, self).__init__(*args, **kwargs) + # Due to its use of different OIDs for different actions, we only form + # an OID that holds the common substring of the OIDs for power + # operations. + self.oid_base = self.oid_enterprise + self.oid_device + + def _snmp_oid(self, oid): + """Return the OID for one of the outlet control objects. + + :param oid: The action-dependent portion of the OID, as a tuple of + integers. + + :returns: The full OID as a tuple of integers. + """ + + outlet = self.snmp_info['outlet'] + full_oid = self.oid_base + oid + (outlet,) + return full_oid + + def _snmp_power_state(self): + oid = self._snmp_oid(self.oid_power_status) + state = self.client.get(oid) + + # Translate the state to an Ironic power state. + if state in (self.on, self.on2off): + power_state = states.POWER_ON + elif state in (self.off, self.off2on): + power_state = states.POWER_OFF + else: + LOG.warning("Vertiv Geist PDU %(addr)s oid %(oid)s outlet " + "%(outlet)s: unrecognised power state %(state)s.", + {'addr': self.snmp_info['address'], + 'oid': oid, + 'outlet': self.snmp_info['outlet'], + 'state': state}) + power_state = states.ERROR + + return power_state + + def _snmp_power_on(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_on) + self.client.set(oid, value) + + def _snmp_power_off(self): + oid = self._snmp_oid(self.oid_power_action) + value = snmp.Integer(self.value_power_off) + self.client.set(oid, value) + + class SNMPDriverAuto(SNMPDriverBase): SYS_OBJ_OID = (1, 3, 6, 1, 2, 1, 1, 2) @@ -878,6 +1213,10 @@ DRIVER_CLASSES = { 'eatonpower': SNMPDriverEatonPower, 'teltronix': SNMPDriverTeltronix, 'baytech_mrp27': SNMPDriverBaytechMRP27, + 'servertech_sentry3': SNMPDriverServerTechSentry3, + 'servertech_sentry4': SNMPDriverServerTechSentry4, + 'raritan_pdu2': SNMPDriverRaritanPDU2, + 'vertivgeist_pdu': SNMPDriverVertivGeistPDU, 'auto': SNMPDriverAuto, } diff --git a/ironic/tests/unit/common/test_pxe_utils.py b/ironic/tests/unit/common/test_pxe_utils.py index f9d781830..3e57b83ed 100644 --- a/ironic/tests/unit/common/test_pxe_utils.py +++ b/ironic/tests/unit/common/test_pxe_utils.py @@ -1628,6 +1628,26 @@ class PXEBuildKickstartConfigOptionsTestCase(db_base.DbTestCase): params = pxe_utils.build_kickstart_config_options(task) self.assertTrue(params['ks_options'].pop('agent_token')) self.assertEqual(expected, params['ks_options']) + self.assertNotIn('insecure_heartbeat', params) + + @mock.patch.object(deploy_utils, 'get_ironic_api_url', autospec=True) + def test_build_kickstart_config_options_pxe_insecure_heartbeat( + self, api_url_mock): + api_url_mock.return_value = 'http://ironic-api' + self.assertFalse(CONF.anaconda.insecure_heartbeat) + CONF.set_override('insecure_heartbeat', True, 'anaconda') + with task_manager.acquire(self.context, self.node.uuid, + shared=True) as task: + expected = {} + expected['liveimg_url'] = task.node.instance_info['image_url'] + expected['config_drive'] = '' + expected['heartbeat_url'] = ( + 'http://ironic-api/v1/heartbeat/%s' % task.node.uuid + ) + expected['insecure_heartbeat'] = 'true' + params = pxe_utils.build_kickstart_config_options(task) + self.assertTrue(params['ks_options'].pop('agent_token')) + self.assertEqual(expected, params['ks_options']) @mock.patch('ironic.common.utils.render_template', autospec=True) def test_prepare_instance_kickstart_config_not_anaconda_boot(self, diff --git a/ironic/tests/unit/drivers/modules/ilo/test_common.py b/ironic/tests/unit/drivers/modules/ilo/test_common.py index 352eb0837..c3e22453f 100644 --- a/ironic/tests/unit/drivers/modules/ilo/test_common.py +++ b/ironic/tests/unit/drivers/modules/ilo/test_common.py @@ -1,3 +1,4 @@ +# Copyright 2022 Hewlett Packard Enterprise Development LP # Copyright 2014 Hewlett-Packard Development Company, L.P. # All Rights Reserved. # @@ -30,6 +31,7 @@ from oslo_utils import uuidutils from ironic.common import boot_devices from ironic.common import exception +from ironic.common import image_service from ironic.common import images from ironic.common import swift from ironic.conductor import task_manager @@ -374,6 +376,22 @@ class IloCommonMethodsTestCase(BaseIloTest): expected_info = dict(self.info, **ipmi_info) self.assertEqual(expected_info, actual_info) + def test_update_redfish_properties(self): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + redfish_info = { + "redfish_address": "1.2.3.4", + "redfish_username": "admin", + "redfish_password": "fake", + "redfish_verify_ca": None, + "redfish_system_id": "/redfish/v1/Systems/1" + } + task.node.driver_info = self.info + ilo_common.update_redfish_properties(task) + actual_info = task.node.driver_info + expected_info = dict(self.info, **redfish_info) + self.assertEqual(expected_info, actual_info) + def test__get_floppy_image_name(self): image_name_expected = 'image-' + self.node.uuid image_name_actual = ilo_common._get_floppy_image_name(self.node) @@ -1504,3 +1522,37 @@ class IloCommonMethodsTestCase(BaseIloTest): self.assertRaises(exception.IloOperationError, ilo_common.setup_uefi_https, task, iso, True) + + @mock.patch.object(image_service, 'FileImageService', spec_set=True, + autospec=True) + @mock.patch.object(image_service, 'HttpImageService', spec_set=True, + autospec=True) + @mock.patch.object(builtins, 'open', autospec=True) + def test_download_file_url(self, open_mock, http_mock, file_mock): + url = "file:///test1/iLO.crt" + target_file = "/a/b/c" + fd_mock = mock.MagicMock(spec=io.BytesIO) + open_mock.return_value = fd_mock + fd_mock.__enter__.return_value = fd_mock + ilo_common.download(target_file, url) + open_mock.assert_called_once_with(target_file, 'wb') + http_mock.assert_not_called() + file_mock.return_value.download.assert_called_once_with( + "/test1/iLO.crt", fd_mock) + + @mock.patch.object(image_service, 'FileImageService', spec_set=True, + autospec=True) + @mock.patch.object(image_service, 'HttpImageService', spec_set=True, + autospec=True) + @mock.patch.object(builtins, 'open', autospec=True) + def test_download_http_url(self, open_mock, http_mock, file_mock): + url = "http://1.1.1.1/iLO.crt" + target_file = "/a/b/c" + fd_mock = mock.MagicMock(spec=io.BytesIO) + open_mock.return_value = fd_mock + fd_mock.__enter__.return_value = fd_mock + ilo_common.download(target_file, url) + http_mock.return_value.download.assert_called_once_with( + "http://1.1.1.1/iLO.crt", fd_mock) + file_mock.assert_not_called() + open_mock.assert_called_once_with(target_file, 'wb') diff --git a/ironic/tests/unit/drivers/modules/ilo/test_management.py b/ironic/tests/unit/drivers/modules/ilo/test_management.py index e4d891c3d..f087c4d58 100644 --- a/ironic/tests/unit/drivers/modules/ilo/test_management.py +++ b/ironic/tests/unit/drivers/modules/ilo/test_management.py @@ -14,9 +14,12 @@ """Test class for Management Interface used by iLO modules.""" +import os +import shutil from unittest import mock import ddt +from oslo_config import cfg from oslo_utils import importutils from oslo_utils import uuidutils @@ -42,6 +45,8 @@ ilo_error = importutils.try_import('proliantutils.exception') INFO_DICT = db_utils.get_test_ilo_info() +CONF = cfg.CONF + @ddt.ddt class IloManagementTestCase(test_common.BaseIloTest): @@ -424,6 +429,116 @@ class IloManagementTestCase(test_common.BaseIloTest): step_mock.assert_called_once_with( task.node, 'update_authentication_failure_logging', '1', False) + @mock.patch.object(ilo_management, '_execute_ilo_step', + spec_set=True, autospec=True) + @mock.patch.object(os, 'makedirs', spec_set=True, autospec=True) + def test_create_csr(self, os_mock, step_mock): + csr_params_args = { + "City": "Bangalore", + "CommonName": "1.1.1.1", + "Country": "ABC", + "OrgName": "DEF", + "State": "IJK" + } + csr_args = { + "csr_params": csr_params_args} + CONF.ilo.cert_path = "/var/lib/ironic/ilo" + + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.driver.management.create_csr(task, **csr_args) + cert_path = os.path.join(CONF.ilo.cert_path, self.node.uuid) + step_mock.assert_called_once_with(task.node, 'create_csr', + cert_path, csr_params_args) + os_mock.assert_called_once_with(cert_path, 0o755) + + @mock.patch.object(ilo_management, '_execute_ilo_step', + spec_set=True, autospec=True) + @mock.patch.object(os, 'makedirs', spec_set=True, autospec=True) + @mock.patch.object(shutil, 'copy', spec_set=True, autospec=True) + def test_add_https_certificate(self, shutil_mock, os_mock, + step_mock): + CONF.ilo.cert_path = "/var/lib/ironic/ilo" + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + cert_file_args = {'cert_file': '/test1/cert'} + task.driver.management.add_https_certificate( + task, **cert_file_args) + cert_path = os.path.join(CONF.ilo.cert_path, self.node.uuid) + cert_path_name = os.path.join(cert_path, self.node.uuid) + filename = cert_path_name + ".crt" + step_mock.assert_called_once_with( + task.node, 'add_https_certificate', filename) + os_mock.assert_called_once_with(cert_path, 0o755) + shutil_mock.assert_called_once_with('/test1/cert', filename) + + @mock.patch.object(ilo_management, '_execute_ilo_step', + spec_set=True, autospec=True) + @mock.patch.object(os, 'makedirs', spec_set=True, autospec=True) + @mock.patch.object(shutil, 'copy', spec_set=True, autospec=True) + @mock.patch.object(ilo_common, 'download', spec_set=True, autospec=True) + def test_add_https_certificate_fileurl(self, download_mock, shutil_mock, + os_mock, step_mock): + CONF.ilo.cert_path = "/var/lib/ironic/ilo" + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + cert_file_args = {'cert_file': 'file:///test1/cert'} + task.driver.management.add_https_certificate( + task, **cert_file_args) + cert_path = os.path.join(CONF.ilo.cert_path, self.node.uuid) + cert_path_name = os.path.join(cert_path, self.node.uuid) + fname = cert_path_name + ".crt" + step_mock.assert_called_once_with( + task.node, 'add_https_certificate', fname) + os_mock.assert_called_once_with(cert_path, 0o755) + shutil_mock.assert_not_called() + download_mock.assert_called_once_with(fname, 'file:///test1/cert') + + @mock.patch.object(ilo_management, '_execute_ilo_step', + spec_set=True, autospec=True) + @mock.patch.object(os, 'makedirs', spec_set=True, autospec=True) + @mock.patch.object(shutil, 'copy', spec_set=True, autospec=True) + @mock.patch.object(ilo_common, 'download', spec_set=True, autospec=True) + def test_add_https_certificate_httpurl(self, download_mock, shutil_mock, + os_mock, step_mock): + CONF.ilo.cert_path = "/var/lib/ironic/ilo" + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + cert_file_args = {'cert_file': 'http://1.1.1.1/cert'} + task.driver.management.add_https_certificate( + task, **cert_file_args) + cert_path = os.path.join(CONF.ilo.cert_path, self.node.uuid) + cert_path_name = os.path.join(cert_path, self.node.uuid) + fname = cert_path_name + ".crt" + step_mock.assert_called_once_with( + task.node, 'add_https_certificate', fname) + os_mock.assert_called_once_with(cert_path, 0o755) + shutil_mock.assert_not_called() + download_mock.assert_called_once_with(fname, 'http://1.1.1.1/cert') + + @mock.patch.object(ilo_management, '_execute_ilo_step', + spec_set=True, autospec=True) + @mock.patch.object(os, 'makedirs', spec_set=True, autospec=True) + @mock.patch.object(shutil, 'copy', spec_set=True, autospec=True) + @mock.patch.object(ilo_common, 'download', spec_set=True, autospec=True) + def test_add_https_certificate_url_exception(self, download_mock, + shutil_mock, os_mock, + step_mock): + CONF.ilo.cert_path = "/var/lib/ironic/ilo" + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + cert_file_args = {'cert_file': 'swift://1.1.1.1/cert'} + self.assertRaises(exception.IloOperationNotSupported, + task.driver.management.add_https_certificate, + task, + **cert_file_args) + + cert_path = os.path.join(CONF.ilo.cert_path, self.node.uuid) + step_mock.assert_not_called() + os_mock.assert_called_once_with(cert_path, 0o755) + shutil_mock.assert_not_called() + download_mock.assert_not_called() + @mock.patch.object(deploy_utils, 'build_agent_options', spec_set=True, autospec=True) @mock.patch.object(ilo_boot.IloVirtualMediaBoot, 'clean_up_ramdisk', diff --git a/ironic/tests/unit/drivers/modules/ilo/test_vendor.py b/ironic/tests/unit/drivers/modules/ilo/test_vendor.py index f3114826e..b7bc3cbce 100644 --- a/ironic/tests/unit/drivers/modules/ilo/test_vendor.py +++ b/ironic/tests/unit/drivers/modules/ilo/test_vendor.py @@ -1,3 +1,4 @@ +# Copyright 2022 Hewlett Packard Enterprise Development LP # Copyright 2015 Hewlett-Packard Development Company, L.P. # All Rights Reserved. # @@ -30,6 +31,7 @@ from ironic.tests.unit.drivers.modules.ilo import test_common class VendorPassthruTestCase(test_common.BaseIloTest): boot_interface = 'ilo-virtual-media' + vendor_interface = 'ilo' @mock.patch.object(manager_utils, 'node_power_action', spec_set=True, autospec=True) @@ -95,3 +97,72 @@ class VendorPassthruTestCase(test_common.BaseIloTest): task, info) validate_image_prop_mock.assert_called_once_with( task.context, 'foo') + + @mock.patch.object(ilo_common, 'get_ilo_object', spec_set=True, + autospec=True) + def test__validate_is_it_a_supported_system( + self, get_ilo_object_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node.maintenance = True + ilo_mock_object = get_ilo_object_mock.return_value + ilo_mock_object.get_product_name.return_value = ( + 'ProLiant DL380 Gen10') + task.driver.vendor._validate_is_it_a_supported_system(task) + get_ilo_object_mock.assert_called_once_with(task.node) + + @mock.patch.object(ilo_common, 'get_ilo_object', spec_set=True, + autospec=True) + def test__validate_is_it_a_supported_system_exception( + self, get_ilo_object_mock): + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + task.node.maintenance = True + ilo_mock_object = get_ilo_object_mock.return_value + ilo_mock_object.get_product_name.return_value = ( + 'ProLiant DL380 Gen8') + self.assertRaises( + exception.IloOperationNotSupported, + task.driver.vendor._validate_is_it_a_supported_system, task) + + @mock.patch.object(ilo_common, 'parse_driver_info', + spec_set=True, autospec=True) + @mock.patch.object(ilo_common, 'update_redfish_properties', + spec_set=True, autospec=True) + @mock.patch.object(ilo_vendor.VendorPassthru, + '_validate_is_it_a_supported_system', + spec_set=True, autospec=True) + def test_validate_create_subscription(self, validate_redfish_system_mock, + redfish_properties_mock, + driver_info_mock): + self.node.vendor_interface = 'ilo' + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + d_info = {'ilo_address': '1.1.1.1', + 'ilo_username': 'user', + 'ilo_password': 'password', + 'ilo_verify_ca': False} + driver_info_mock.return_value = d_info + redfish_properties = {'redfish_address': '1.1.1.1', + 'redfish_username': 'user', + 'redfish_password': 'password', + 'redfish_system_id': '/redfish/v1/Systems/1', + 'redfish_verify_ca': False} + redfish_properties_mock.return_value = redfish_properties + kwargs = {'Destination': 'https://someulr', + 'Context': 'MyProtocol'} + task.driver.vendor.validate(task, 'create_subscription', **kwargs) + driver_info_mock.assert_called_once_with(task.node) + redfish_properties_mock.assert_called_once_with(task) + validate_redfish_system_mock.assert_called_once_with( + task.driver.vendor, task) + + def test_validate_operation_exeption(self): + self.node.vendor_interface = 'ilo' + self.node.save() + with task_manager.acquire(self.context, self.node.uuid, + shared=False) as task: + self.assertRaises( + exception.IloOperationNotSupported, + task.driver.vendor.validate, task, 'eject_vmedia') diff --git a/ironic/tests/unit/drivers/modules/redfish/test_utils.py b/ironic/tests/unit/drivers/modules/redfish/test_utils.py index ca8aba9da..01b7089c7 100644 --- a/ironic/tests/unit/drivers/modules/redfish/test_utils.py +++ b/ironic/tests/unit/drivers/modules/redfish/test_utils.py @@ -252,6 +252,7 @@ class RedfishUtilsAuthTestCase(db_base.DbTestCase): redfish_utils.get_system(self.node) redfish_utils.get_system(self.node) self.assertEqual(1, mock_sushy.call_count) + self.assertEqual(len(redfish_utils.SessionCache._sessions), 1) @mock.patch.object(sushy, 'Sushy', autospec=True) def test_ensure_new_session_address(self, mock_sushy): @@ -270,6 +271,21 @@ class RedfishUtilsAuthTestCase(db_base.DbTestCase): self.assertEqual(2, mock_sushy.call_count) @mock.patch.object(sushy, 'Sushy', autospec=True) + def test_ensure_new_session_password(self, mock_sushy): + d_info = self.node.driver_info + d_info['redfish_username'] = 'foo' + d_info['redfish_password'] = 'bar' + self.node.driver_info = d_info + self.node.save() + redfish_utils.get_system(self.node) + d_info['redfish_password'] = 'foo' + self.node.driver_info = d_info + self.node.save() + redfish_utils.SessionCache._sessions = collections.OrderedDict() + redfish_utils.get_system(self.node) + self.assertEqual(2, mock_sushy.call_count) + + @mock.patch.object(sushy, 'Sushy', autospec=True) @mock.patch('ironic.drivers.modules.redfish.utils.' 'SessionCache.AUTH_CLASSES', autospec=True) @mock.patch('ironic.drivers.modules.redfish.utils.SessionCache._sessions', diff --git a/ironic/tests/unit/drivers/modules/test_snmp.py b/ironic/tests/unit/drivers/modules/test_snmp.py index 6bdd2da5a..00799dc4d 100644 --- a/ironic/tests/unit/drivers/modules/test_snmp.py +++ b/ironic/tests/unit/drivers/modules/test_snmp.py @@ -327,6 +327,34 @@ class SNMPValidateParametersTestCase(db_base.DbTestCase): info = snmp._parse_driver_info(node) self.assertEqual('teltronix', info['driver']) + def test__parse_driver_info_servertech_sentry3(self): + # Make sure the servertech_sentry3 driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='servertech_sentry3') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('servertech_sentry3', info['driver']) + + def test__parse_driver_info_servertech_sentry4(self): + # Make sure the servertech_sentry4 driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='servertech_sentry4') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('servertech_sentry4', info['driver']) + + def test__parse_driver_info_raritan_pdu2(self): + # Make sure the raritan_pdu2 driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='raritan_pdu2') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('raritan_pdu2', info['driver']) + + def test__parse_driver_info_vertivgeist_pdu(self): + # Make sure the vertivgeist_pdu driver type is parsed. + info = db_utils.get_test_snmp_info(snmp_driver='vertivgeist_pdu') + node = self._get_test_node(info) + info = snmp._parse_driver_info(node) + self.assertEqual('vertivgeist_pdu', info['driver']) + def test__parse_driver_info_snmp_v1(self): # Make sure SNMPv1 is parsed with a community string. info = db_utils.get_test_snmp_info(snmp_version='1', @@ -1260,6 +1288,58 @@ class SNMPDeviceDriverTestCase(db_base.DbTestCase): def test_apc_rackpdu_power_reset(self, mock_get_client): self._test_simple_device_power_reset('apc_rackpdu', mock_get_client) + def test_raritan_pdu2_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the + # Raritan PDU2 driver + self._update_driver_info(snmp_driver="raritan_pdu2", + snmp_outlet="6") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 13742, 6, 4, 1, 2, 1, 2, 1, 6) + action = (2,) + + self.assertEqual(oid, driver._snmp_oid(action)) + self.assertEqual(1, driver.value_power_on) + self.assertEqual(0, driver.value_power_off) + + def test_servertech_sentry3_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the + # ServerTech Sentry3 driver + self._update_driver_info(snmp_driver="servertech_sentry3", + snmp_outlet="6") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 1718, 3, 2, 3, 1, 5, 1, 1, 6) + action = (5,) + + self.assertEqual(oid, driver._snmp_oid(action)) + self.assertEqual(1, driver.value_power_on) + self.assertEqual(2, driver.value_power_off) + + def test_servertech_sentry4_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the + # ServerTech Sentry4 driver + self._update_driver_info(snmp_driver="servertech_sentry4", + snmp_outlet="6") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 1718, 4, 1, 8, 5, 1, 2, 1, 1, 6) + action = (2,) + + self.assertEqual(oid, driver._snmp_oid(action)) + self.assertEqual(1, driver.value_power_on) + self.assertEqual(2, driver.value_power_off) + + def test_vertivgeist_pdu_snmp_objects(self, mock_get_client): + # Ensure the correct SNMP object OIDs and values are used by the + # Vertiv Geist PDU driver + self._update_driver_info(snmp_driver="vertivgeist_pdu", + snmp_outlet="6") + driver = snmp._get_driver(self.node) + oid = (1, 3, 6, 1, 4, 1, 21239, 5, 2, 3, 5, 1, 4, 6) + action = (4,) + + self.assertEqual(oid, driver._snmp_oid(action)) + self.assertEqual(2, driver.value_power_on) + self.assertEqual(4, driver.value_power_off) + def test_aten_snmp_objects(self, mock_get_client): # Ensure the correct SNMP object OIDs and values are used by the # Aten driver diff --git a/releasenotes/notes/additonal-snmp-drivers-ae1174e6bd6ee3a6.yaml b/releasenotes/notes/additonal-snmp-drivers-ae1174e6bd6ee3a6.yaml new file mode 100644 index 000000000..f98f2e607 --- /dev/null +++ b/releasenotes/notes/additonal-snmp-drivers-ae1174e6bd6ee3a6.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Adds ``raritan_pdu2``, ``servertech_sentry3``, ``servertech_sentry4``, + and ``vertivgest_pdu`` snmp drivers to support additional PDU models. diff --git a/releasenotes/notes/anaconda-permit-cert-validation-disable-6611d3cb9401031d.yaml b/releasenotes/notes/anaconda-permit-cert-validation-disable-6611d3cb9401031d.yaml new file mode 100644 index 000000000..59d306c5d --- /dev/null +++ b/releasenotes/notes/anaconda-permit-cert-validation-disable-6611d3cb9401031d.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + Adds a configuration option, ``[anaconda]insecure_heartbeat`` to allow + for TLS certificate validation to be disabled in the ``anaconda`` + deployment interface, which is needed for continious integration to + be able to be performed without substantial substrate image customization. + This option is *not* advised for any production usage. diff --git a/releasenotes/notes/create_csr_clean_step-a720932f61b42118.yaml b/releasenotes/notes/create_csr_clean_step-a720932f61b42118.yaml new file mode 100644 index 000000000..1951245c1 --- /dev/null +++ b/releasenotes/notes/create_csr_clean_step-a720932f61b42118.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Adds new clean steps ``create_csr`` and ``add_https_certificate`` + to ``ilo`` and ``ilo5`` hardware types which allows users to + create Certificate Signing Request(CSR) and adds signed HTTPS + certificate to the iLO. diff --git a/releasenotes/notes/ilo-event-subscription-0dadf136411bd16a.yaml b/releasenotes/notes/ilo-event-subscription-0dadf136411bd16a.yaml new file mode 100644 index 000000000..fcfc515e4 --- /dev/null +++ b/releasenotes/notes/ilo-event-subscription-0dadf136411bd16a.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Provides vendor passthru methods for ``ilo`` and ``ilo5`` hardware types + to create, delete and get subscriptions for BMC events. These methods are + supported for ``HPE ProLiant Gen10`` and ``HPE ProLiant Gen10 Plus`` + servers. diff --git a/releasenotes/notes/redfish_consider_password_in_session_cache-1fa84234db179053.yaml b/releasenotes/notes/redfish_consider_password_in_session_cache-1fa84234db179053.yaml new file mode 100644 index 000000000..af48b88fa --- /dev/null +++ b/releasenotes/notes/redfish_consider_password_in_session_cache-1fa84234db179053.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + Fixes an issue where the Redfish session cache would continue using an + old session when a password for a Redfish BMC was changed. Now the old + session will not be found in this case, and a new session will be created + with the latest credential information available. diff --git a/releasenotes/notes/skip-clear-job-queue-idrac-reset-if-attr-missing-b2a2b609c906c6c4.yaml b/releasenotes/notes/skip-clear-job-queue-idrac-reset-if-attr-missing-b2a2b609c906c6c4.yaml index df9bef955..a829cbd97 100644 --- a/releasenotes/notes/skip-clear-job-queue-idrac-reset-if-attr-missing-b2a2b609c906c6c4.yaml +++ b/releasenotes/notes/skip-clear-job-queue-idrac-reset-if-attr-missing-b2a2b609c906c6c4.yaml @@ -1,8 +1,8 @@ --- fixes: - | - Resolved clear_job_queue and reset_idrac verify step failures which occur - when the functionality is not supported by the iDRAC. When this condition - is detected, the code in the step handles the exception and logs a warning - and completes successfully in case of verification steps but fails in case - of cleaning steps. + Resolved ``clear_job_queue`` and ``reset_idrac`` verify step failures which + occur when the functionality is not supported by the iDRAC. When this + condition is detected, the code in the step handles the exception and logs + a warning and completes successfully in case of verification steps but + fails in case of cleaning steps. diff --git a/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po b/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po index f5f519da5..d4d148d41 100644 --- a/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po +++ b/releasenotes/source/locale/en_GB/LC_MESSAGES/releasenotes.po @@ -7,11 +7,11 @@ msgid "" msgstr "" "Project-Id-Version: Ironic Release Notes\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-07-06 13:27+0000\n" +"POT-Creation-Date: 2022-09-06 22:51+0000\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2022-07-24 12:21+0000\n" +"PO-Revision-Date: 2022-09-05 10:29+0000\n" "Last-Translator: Andi Chandler <andi@gowling.com>\n" "Language-Team: English (United Kingdom)\n" "Language: en_GB\n" @@ -211,8 +211,8 @@ msgstr "13.0.6" msgid "13.0.7" msgstr "13.0.7" -msgid "13.0.7-24" -msgstr "13.0.7-24" +msgid "13.0.7-25" +msgstr "13.0.7-25" msgid "14.0.0" msgstr "14.0.0" @@ -226,8 +226,8 @@ msgstr "15.0.1" msgid "15.0.2" msgstr "15.0.2" -msgid "15.0.2-16" -msgstr "15.0.2-16" +msgid "15.0.2-17" +msgstr "15.0.2-17" msgid "15.1.0" msgstr "15.1.0" @@ -271,8 +271,8 @@ msgstr "17.0.3" msgid "17.0.4" msgstr "17.0.4" -msgid "17.0.4-27" -msgstr "17.0.4-27" +msgid "17.0.4-34" +msgstr "17.0.4-34" msgid "18.0.0" msgstr "18.0.0" @@ -286,8 +286,8 @@ msgstr "18.2.0" msgid "18.2.1" msgstr "18.2.1" -msgid "18.2.1-17" -msgstr "18.2.1-17" +msgid "18.2.1-27" +msgstr "18.2.1-27" msgid "19.0.0" msgstr "19.0.0" @@ -298,14 +298,14 @@ msgstr "20.0.0" msgid "20.1.0" msgstr "20.1.0" -msgid "20.1.0-12" -msgstr "20.1.0-12" +msgid "20.1.0-24" +msgstr "20.1.0-24" msgid "20.2.0" msgstr "20.2.0" -msgid "20.2.0-21" -msgstr "20.2.0-21" +msgid "21.0.0" +msgstr "21.0.0" msgid "4.0.0 First semver release" msgstr "4.0.0 First semver release" @@ -613,6 +613,13 @@ msgstr "" "mod_wsgi)." msgid "" +"A new class ``ironic.drivers.modules.agent.CustomAgentDeploy`` can be used " +"as a base class for deploy interfaces based on ironic-python-agent." +msgstr "" +"A new class ``ironic.drivers.modules.agent.CustomAgentDeploy`` can be used " +"as a base class for deploying interfaces based on ironic-python-agent." + +msgid "" "A new configuration option ``[agent]require_tls`` allows rejecting ramdisk " "callback URLs that don't use the ``https://`` schema." msgstr "" diff --git a/requirements.txt b/requirements.txt index 24c09f50c..ae8e14f39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -47,4 +47,4 @@ psutil>=3.2.2 # BSD futurist>=1.2.0 # Apache-2.0 tooz>=2.7.0 # Apache-2.0 openstacksdk>=0.48.0 # Apache-2.0 -sushy>=3.10.0 +sushy>=4.3.0 |