diff --git a/doc/source/dev/webapi-version-history.rst b/doc/source/dev/webapi-version-history.rst index 4998eac5a4..0b69ff3725 100644 --- a/doc/source/dev/webapi-version-history.rst +++ b/doc/source/dev/webapi-version-history.rst @@ -2,6 +2,54 @@ REST API Version History ======================== +**1.30** (Ocata) + + Added dynamic driver APIs. + + * GET /v1/drivers now accepts a ``type`` parameter (optional, one of + ``classic`` or ``dynamic``), to limit the result to only classic drivers + or dynamic drivers (hardware types). Without this parameter, both + classic and dynamic drivers are returned. + + * GET /v1/drivers now accepts a ``detail`` parameter (optional, one of + ``True`` or ``False``), to show all fields for a driver. Defaults to + ``False``. + + * GET /v1/drivers now returns an additional ``type`` field to show if the + driver is classic or dynamic. + + * GET /v1/drivers/ now returns an additional ``type`` field to show + if the driver is classic or dynamic. + + * GET /v1/drivers/ now returns additional fields that are null for + classic drivers, and set as following for dynamic drivers: + + * The value of the default__interface is the entrypoint + name of the calculated default interface for that type: + + * default_boot_interface + * default_console_interface + * default_deploy_interface + * default_inspect_interface + * default_management_interface + * default_network_interface + * default_power_interface + * default_raid_interface + * default_vendor_interface + + * The value of the enabled__interfaces is a list of + entrypoint names of the enabled interfaces for that type: + + * enabled_boot_interfaces + * enabled_console_interfaces + * enabled_deploy_interfaces + * enabled_inspect_interfaces + * enabled_management_interfaces + * enabled_network_interfaces + * enabled_power_interfaces + * enabled_raid_interfaces + * enabled_vendor_interfaces + **1.29** (Ocata) Add a new management API to support inject NMI, diff --git a/ironic/api/controllers/v1/driver.py b/ironic/api/controllers/v1/driver.py index d2dbc12c26..34fa9423b4 100644 --- a/ironic/api/controllers/v1/driver.py +++ b/ironic/api/controllers/v1/driver.py @@ -27,6 +27,7 @@ from ironic.api.controllers.v1 import utils as api_utils from ironic.api import expose from ironic.common import exception from ironic.common import policy +from ironic.drivers import base as driver_base METRICS = metrics_utils.get_metrics_logger(__name__) @@ -71,14 +72,52 @@ class Driver(base.APIBase): hosts = [wtypes.text] """A list of active conductors that support this driver""" + type = wtypes.text + """Whether the driver is classic or dynamic (hardware type)""" + links = wsme.wsattr([link.Link], readonly=True) """A list containing self and bookmark links""" properties = wsme.wsattr([link.Link], readonly=True) """A list containing links to driver properties""" + """Default interface for a hardware type""" + default_boot_interface = wtypes.text + default_console_interface = wtypes.text + default_deploy_interface = wtypes.text + default_inspect_interface = wtypes.text + default_management_interface = wtypes.text + default_network_interface = wtypes.text + default_power_interface = wtypes.text + default_raid_interface = wtypes.text + default_vendor_interface = wtypes.text + + """A list of enabled interfaces for a hardware type""" + enabled_boot_interfaces = [wtypes.text] + enabled_console_interfaces = [wtypes.text] + enabled_deploy_interfaces = [wtypes.text] + enabled_inspect_interfaces = [wtypes.text] + enabled_management_interfaces = [wtypes.text] + enabled_network_interfaces = [wtypes.text] + enabled_power_interfaces = [wtypes.text] + enabled_raid_interfaces = [wtypes.text] + enabled_vendor_interfaces = [wtypes.text] + @staticmethod - def convert_with_links(name, hosts): + def convert_with_links(name, hosts, driver_type, detail=False, + interface_info=None): + """Convert driver/hardware type info to an API-serializable object. + + :param name: name of driver or hardware type. + :param hosts: list of conductor hostnames driver is active on. + :param driver_type: 'classic' for classic drivers, 'dynamic' for + hardware types. + :param detail: boolean, whether to include detailed info, such as + the 'type' field and default/enabled interfaces fields. + :param interface_info: optional list of dicts of hardware interface + info. + :returns: API-serializable driver object. + """ driver = Driver() driver.name = name driver.hosts = hosts @@ -101,12 +140,51 @@ class Driver(base.APIBase): 'drivers', name + "/properties", bookmark=True) ] + + if api_utils.allow_dynamic_drivers(): + driver.type = driver_type + if driver_type == 'dynamic' and detail: + if interface_info is None: + # TODO(jroll) objectify this + interface_info = (pecan.request.dbapi + .list_hardware_type_interfaces([name])) + for iface_type in driver_base.ALL_INTERFACES: + default = None + enabled = set() + for iface in interface_info: + if iface['interface_type'] == iface_type: + iface_name = iface['interface_name'] + enabled.add(iface_name) + # NOTE(jroll) this assumes the default is the same + # on all conductors + if iface['default']: + default = iface_name + + default_key = 'default_%s_interface' % iface_type + enabled_key = 'enabled_%s_interfaces' % iface_type + setattr(driver, default_key, default) + setattr(driver, enabled_key, list(enabled)) + + elif detail: + for iface_type in driver_base.ALL_INTERFACES: + # always return None for classic drivers + setattr(driver, 'default_%s_interface' % iface_type, None) + setattr(driver, 'enabled_%s_interfaces' % iface_type, None) + return driver @classmethod def sample(cls): - sample = cls(name="sample-driver", - hosts=["fake-host"]) + attrs = { + 'name': 'sample-driver', + 'hosts': ['fake-host'], + 'type': 'classic', + } + for iface_type in driver_base.ALL_INTERFACES: + attrs['default_%s_interface' % iface_type] = None + attrs['enabled_%s_interfaces' % iface_type] = None + + sample = cls(**attrs) return sample @@ -117,11 +195,40 @@ class DriverList(base.APIBase): """A list containing drivers objects""" @staticmethod - def convert_with_links(drivers): + def convert_with_links(drivers, hardware_types, detail=False): + """Convert drivers and hardware types to an API-serializable object. + + :param drivers: dict mapping driver names to conductor hostnames. + :param hardware_types: dict mapping hardware type names to conductor + hostnames. + :param detail: boolean, whether to include detailed info, such as + the 'type' field and default/enabled interfaces fields. + :returns: an API-serializable driver collection object. + """ collection = DriverList() collection.drivers = [ - Driver.convert_with_links(dname, list(drivers[dname])) + Driver.convert_with_links(dname, list(drivers[dname]), 'classic', + detail=detail) for dname in drivers] + + # NOTE(jroll) we return hardware types in all API versions, + # but restrict type/default/enabled fields to 1.30. + # This is checked in Driver.convert_with_links(), however also + # checking here can save us a DB query. + if api_utils.allow_dynamic_drivers() and detail: + iface_info = pecan.request.dbapi.list_hardware_type_interfaces( + list(hardware_types)) + else: + iface_info = [] + + for htname in hardware_types: + interface_info = [i for i in iface_info + if i['hardware_type'] == htname] + collection.drivers.append( + Driver.convert_with_links(htname, + list(hardware_types[htname]), + 'dynamic', detail=detail, + interface_info=interface_info)) return collection @classmethod @@ -243,8 +350,8 @@ class DriversController(rest.RestController): } @METRICS.timer('DriversController.get_all') - @expose.expose(DriverList) - def get_all(self): + @expose.expose(DriverList, wtypes.text, types.boolean) + def get_all(self, type=None, detail=None): """Retrieve a list of drivers.""" # FIXME(deva): formatting of the auto-generated REST API docs # will break from a single-line doc string. @@ -253,8 +360,21 @@ class DriversController(rest.RestController): cdict = pecan.request.context.to_policy_values() policy.authorize('baremetal:driver:get', cdict, cdict) - driver_list = pecan.request.dbapi.get_active_driver_dict() - return DriverList.convert_with_links(driver_list) + api_utils.check_allow_driver_detail(detail) + api_utils.check_allow_filter_driver_type(type) + if type not in (None, 'classic', 'dynamic'): + raise exception.Invalid(_( + '"type" filter must be one of "classic" or "dynamic", ' + 'if specified.')) + + driver_list = {} + hw_type_dict = {} + if type is None or type == 'classic': + driver_list = pecan.request.dbapi.get_active_driver_dict() + if type is None or type == 'dynamic': + hw_type_dict = pecan.request.dbapi.get_active_hardware_type_dict() + return DriverList.convert_with_links(driver_list, hw_type_dict, + detail=detail) @METRICS.timer('DriversController.get_one') @expose.expose(Driver, wtypes.text) @@ -267,10 +387,20 @@ class DriversController(rest.RestController): cdict = pecan.request.context.to_policy_values() policy.authorize('baremetal:driver:get', cdict, cdict) + def _find_driver(driver_dict, driver_type): + for name, hosts in driver_dict.items(): + if name == driver_name: + return Driver.convert_with_links(name, list(hosts), + driver_type, detail=True) + + hw_type_dict = pecan.request.dbapi.get_active_hardware_type_dict() + driver = _find_driver(hw_type_dict, 'dynamic') + if driver: + return driver driver_dict = pecan.request.dbapi.get_active_driver_dict() - for name, hosts in driver_dict.items(): - if name == driver_name: - return Driver.convert_with_links(name, list(hosts)) + driver = _find_driver(driver_dict, 'classic') + if driver: + return driver raise exception.DriverNotFound(driver_name=driver_name) diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 40a9a1369a..ba5ebf1a95 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -350,6 +350,32 @@ def check_allow_specify_resource_class(resource_class): 'opr': versions.MINOR_21_RESOURCE_CLASS}) +def check_allow_filter_driver_type(driver_type): + """Check if filtering drivers by classic/dynamic is allowed. + + Version 1.30 of the API allows this. + """ + if driver_type is not None and not allow_dynamic_drivers(): + raise exception.NotAcceptable(_( + "Request not acceptable. The minimal required API version " + "should be %(base)s.%(opr)s") % + {'base': versions.BASE_VERSION, + 'opr': versions.MINOR_30_DYNAMIC_DRIVERS}) + + +def check_allow_driver_detail(detail): + """Check if getting detailed driver info is allowed. + + Version 1.30 of the API allows this. + """ + if detail is not None and not allow_dynamic_drivers(): + raise exception.NotAcceptable(_( + "Request not acceptable. The minimal required API version " + "should be %(base)s.%(opr)s") % + {'base': versions.BASE_VERSION, + 'opr': versions.MINOR_30_DYNAMIC_DRIVERS}) + + def initial_node_provision_state(): """Return node state to use by default when creating new nodes. @@ -489,6 +515,16 @@ def allow_vifs_subcontroller(): versions.MINOR_28_VIFS_SUBCONTROLLER) +def allow_dynamic_drivers(): + """Check if dynamic driver API calls are allowed. + + Version 1.30 of the API added support for all of the driver + composition related calls in the /v1/drivers API. + """ + return (pecan.request.version.minor >= + versions.MINOR_30_DYNAMIC_DRIVERS) + + def get_controller_reserved_names(cls): """Get reserved names for a given controller. diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 9cb46fe30a..188ecdc4c6 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -60,6 +60,7 @@ BASE_VERSION = 1 # v1.27: Add soft reboot, soft power off and timeout. # v1.28: Add vifs subcontroller to node # v1.29: Add inject nmi. +# v1.30: Add dynamic driver interactions. MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -91,11 +92,12 @@ MINOR_26_PORTGROUP_MODE_PROPERTIES = 26 MINOR_27_SOFT_POWER_OFF = 27 MINOR_28_VIFS_SUBCONTROLLER = 28 MINOR_29_INJECT_NMI = 29 +MINOR_30_DYNAMIC_DRIVERS = 30 # When adding another version, update MINOR_MAX_VERSION and also update # doc/source/dev/webapi-version-history.rst with a detailed explanation of # what the version has changed. -MINOR_MAX_VERSION = MINOR_29_INJECT_NMI +MINOR_MAX_VERSION = MINOR_30_DYNAMIC_DRIVERS # String representations of the minor and maximum versions MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/driver_factory.py b/ironic/common/driver_factory.py index 1b9e9e4103..e6105e6e2a 100644 --- a/ironic/common/driver_factory.py +++ b/ironic/common/driver_factory.py @@ -95,11 +95,11 @@ def _attach_interfaces_to_driver(bare_driver, node, driver_or_hw_type): for iface in dynamic_interfaces: impl_name = getattr(node, '%s_interface' % iface) - impl = _get_interface(driver_or_hw_type, iface, impl_name) + impl = get_interface(driver_or_hw_type, iface, impl_name) setattr(bare_driver, iface, impl) -def _get_interface(driver_or_hw_type, interface_type, interface_name): +def get_interface(driver_or_hw_type, interface_type, interface_name): """Get interface implementation instance. For hardware types also validates compatibility. @@ -170,7 +170,7 @@ def default_interface(driver_or_hw_type, interface_type): if impl_name is not None: # Check that the default is correct for this type - _get_interface(driver_or_hw_type, interface_type, impl_name) + get_interface(driver_or_hw_type, interface_type, impl_name) elif is_hardware_type: supported = getattr(driver_or_hw_type, 'supported_%s_interfaces' % interface_type) @@ -230,7 +230,7 @@ def check_and_update_node_interfaces(node, driver_or_hw_type=None): impl_name = getattr(node, field_name) if impl_name is not None: # Check that the provided value is correct for this type - _get_interface(driver_or_hw_type, iface, impl_name) + get_interface(driver_or_hw_type, iface, impl_name) # Not changing the result, proceeding with the next interface continue diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index cfc438e462..968f728510 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -67,6 +67,7 @@ from ironic.conductor import task_manager from ironic.conductor import utils from ironic.conf import CONF from ironic.drivers import base as drivers_base +from ironic.drivers import hardware_type from ironic import objects from ironic.objects import base as objects_base from ironic.objects import fields @@ -332,7 +333,8 @@ class ConductorManager(base_manager.BaseConductorManager): @messaging.expected_exceptions(exception.NoFreeConductorWorker, exception.InvalidParameterValue, exception.UnsupportedDriverExtension, - exception.DriverNotFound) + exception.DriverNotFound, + exception.InterfaceNotFoundInEntrypoint) def driver_vendor_passthru(self, context, driver_name, driver_method, http_method, info): """Handle top-level vendor actions. @@ -343,8 +345,11 @@ class ConductorManager(base_manager.BaseConductorManager): async the conductor will start background worker to perform vendor action. + For dynamic drivers, the calculated default vendor interface is used. + :param context: an admin context. - :param driver_name: name of the driver on which to call the method. + :param driver_name: name of the driver or hardware type on which to + call the method. :param driver_method: name of the vendor method, for use by the driver. :param http_method: the HTTP method used for the request. :param info: user-supplied data to pass through to the driver. @@ -357,6 +362,8 @@ class ConductorManager(base_manager.BaseConductorManager): :raises: DriverNotFound if the supplied driver is not loaded. :raises: NoFreeConductorWorker when there is no free worker to start async task. + :raises: InterfaceNotFoundInEntrypoint if the default interface for a + hardware type is invalid. :returns: A dictionary containing: :return: The response of the invoked vendor method @@ -371,14 +378,23 @@ class ConductorManager(base_manager.BaseConductorManager): # Any locking in a top-level vendor action will need to be done by the # implementation, as there is little we could reasonably lock on here. LOG.debug("RPC driver_vendor_passthru for driver %s.", driver_name) - driver = driver_factory.get_driver(driver_name) - if not getattr(driver, 'vendor', None): + driver = driver_factory.get_driver_or_hardware_type(driver_name) + vendor = None + if isinstance(driver, hardware_type.AbstractHardwareType): + vendor_name = driver_factory.default_interface(driver, 'vendor') + if vendor_name is not None: + vendor = driver_factory.get_interface(driver, 'vendor', + vendor_name) + else: + vendor = getattr(driver, 'vendor', None) + + if not vendor: raise exception.UnsupportedDriverExtension( driver=driver_name, extension='vendor interface') try: - vendor_opts = driver.vendor.driver_routes[driver_method] + vendor_opts = vendor.driver_routes[driver_method] vendor_func = vendor_opts['func'] except KeyError: raise exception.InvalidParameterValue( @@ -396,7 +412,7 @@ class ConductorManager(base_manager.BaseConductorManager): # Invoke the vendor method accordingly with the mode is_async = vendor_opts['async'] ret = None - driver.vendor.driver_validate(method=driver_method, **info) + vendor.driver_validate(method=driver_method, **info) if is_async: self._spawn_worker(vendor_func, context, **info) @@ -432,12 +448,20 @@ class ConductorManager(base_manager.BaseConductorManager): @METRICS.timer('ConductorManager.get_driver_vendor_passthru_methods') @messaging.expected_exceptions(exception.UnsupportedDriverExtension, - exception.DriverNotFound) + exception.DriverNotFound, + exception.InterfaceNotFoundInEntrypoint) def get_driver_vendor_passthru_methods(self, context, driver_name): """Retrieve information about vendor methods of the given driver. + For dynamic drivers, the default vendor interface is used. + :param context: an admin context. - :param driver_name: name of the driver. + :param driver_name: name of the driver or hardware_type + :raises: UnsupportedDriverExtension if current driver does not have + vendor interface. + :raises: DriverNotFound if the supplied driver is not loaded. + :raises: InterfaceNotFoundInEntrypoint if the default interface for a + hardware type is invalid. :returns: dictionary of : entries. """ @@ -445,13 +469,22 @@ class ConductorManager(base_manager.BaseConductorManager): # implementation, as there is little we could reasonably lock on here. LOG.debug("RPC get_driver_vendor_passthru_methods for driver %s", driver_name) - driver = driver_factory.get_driver(driver_name) - if not getattr(driver, 'vendor', None): + driver = driver_factory.get_driver_or_hardware_type(driver_name) + vendor = None + if isinstance(driver, hardware_type.AbstractHardwareType): + vendor_name = driver_factory.default_interface(driver, 'vendor') + if vendor_name is not None: + vendor = driver_factory.get_interface(driver, 'vendor', + vendor_name) + else: + vendor = getattr(driver, 'vendor', None) + + if not vendor: raise exception.UnsupportedDriverExtension( driver=driver_name, extension='vendor interface') - return get_vendor_passthru_metadata(driver.vendor.driver_routes) + return get_vendor_passthru_metadata(vendor.driver_routes) @METRICS.timer('ConductorManager.do_node_deploy') @messaging.expected_exceptions(exception.NoFreeConductorWorker, @@ -2018,7 +2051,7 @@ class ConductorManager(base_manager.BaseConductorManager): """ LOG.debug("RPC get_driver_properties called for driver %s.", driver_name) - driver = driver_factory.get_driver(driver_name) + driver = driver_factory.get_driver_or_hardware_type(driver_name) return driver.get_properties() @METRICS.timer('ConductorManager._send_sensor_data') @@ -2346,29 +2379,42 @@ class ConductorManager(base_manager.BaseConductorManager): node.save() @METRICS.timer('ConductorManager.get_raid_logical_disk_properties') - @messaging.expected_exceptions(exception.UnsupportedDriverExtension) + @messaging.expected_exceptions(exception.UnsupportedDriverExtension, + exception.InterfaceNotFoundInEntrypoint) def get_raid_logical_disk_properties(self, context, driver_name): """Get the logical disk properties for RAID configuration. Gets the information about logical disk properties which can - be specified in the input RAID configuration. + be specified in the input RAID configuration. For dynamic drivers, + the default vendor interface is used. :param context: request context. :param driver_name: name of the driver :raises: UnsupportedDriverExtension, if the driver doesn't support RAID configuration. + :raises: InterfaceNotFoundInEntrypoint if the default interface for a + hardware type is invalid. :returns: A dictionary containing the properties and a textual description for them. """ LOG.debug("RPC get_raid_logical_disk_properties " "called for driver %s", driver_name) - driver = driver_factory.get_driver(driver_name) - if not getattr(driver, 'raid', None): + driver = driver_factory.get_driver_or_hardware_type(driver_name) + raid_iface = None + if isinstance(driver, hardware_type.AbstractHardwareType): + raid_iface_name = driver_factory.default_interface(driver, 'raid') + if raid_iface_name is not None: + raid_iface = driver_factory.get_interface(driver, 'raid', + raid_iface_name) + else: + raid_iface = getattr(driver, 'raid', None) + + if not raid_iface: raise exception.UnsupportedDriverExtension( driver=driver_name, extension='raid') - return driver.raid.get_logical_disk_properties() + return raid_iface.get_logical_disk_properties() @METRICS.timer('ConductorManager.heartbeat') @messaging.expected_exceptions(exception.NoFreeConductorWorker) diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index 88e540633f..793967ae77 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -266,6 +266,8 @@ class ConductorAPI(object): :raises: DriverNotFound if the supplied driver is not loaded. :raises: NoFreeConductorWorker when there is no free worker to start async task. + :raises: InterfaceNotFoundInEntrypoint if the default interface for a + hardware type is invalid. :returns: A dictionary containing: :return: The response of the invoked vendor method @@ -304,6 +306,11 @@ class ConductorAPI(object): :param context: an admin context. :param driver_name: name of the driver. :param topic: RPC topic. Defaults to self.topic. + :raises: UnsupportedDriverExtension if current driver does not have + vendor interface. + :raises: DriverNotFound if the supplied driver is not loaded. + :raises: InterfaceNotFoundInEntrypoint if the default interface for a + hardware type is invalid. :returns: dictionary of : entries. """ @@ -666,6 +673,8 @@ class ConductorAPI(object): :param topic: RPC topic. Defaults to self.topic. :raises: UnsupportedDriverExtension if the driver doesn't support RAID configuration. + :raises: InterfaceNotFoundInEntrypoint if the default interface for a + hardware type is invalid. :returns: A dictionary containing the properties that can be mentioned for logical disks and a textual description for them. """ diff --git a/ironic/db/api.py b/ironic/db/api.py index 92357f6cd8..c86b7e3c21 100644 --- a/ironic/db/api.py +++ b/ironic/db/api.py @@ -558,6 +558,15 @@ class Connection(object): :returns: List of ``ConductorHardwareInterfaces`` objects. """ + @abc.abstractmethod + def list_hardware_type_interfaces(self, hardware_types): + """List registered hardware interfaces for given hardware types. + + This is restricted to only active conductors. + :param hardware_types: list of hardware types to filter by. + :returns: list of ``ConductorHardwareInterfaces`` objects. + """ + @abc.abstractmethod def register_conductor_hardware_interfaces(self, conductor_id, hardware_type, interface_type, diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py index 69ddbca942..f56216121f 100644 --- a/ironic/db/sqlalchemy/api.py +++ b/ironic/db/sqlalchemy/api.py @@ -826,6 +826,14 @@ class Connection(api.Connection): .filter_by(conductor_id=conductor_id)) return query.all() + def list_hardware_type_interfaces(self, hardware_types): + query = (model_query(models.ConductorHardwareInterfaces) + .filter(models.ConductorHardwareInterfaces.hardware_type + .in_(hardware_types))) + + query = _filter_active_conductors(query) + return query.all() + def register_conductor_hardware_interfaces(self, conductor_id, hardware_type, interface_type, interfaces, default_interface): diff --git a/ironic/drivers/generic.py b/ironic/drivers/generic.py index 4178d4995d..ab4312421a 100644 --- a/ironic/drivers/generic.py +++ b/ironic/drivers/generic.py @@ -84,3 +84,8 @@ class ManualManagementHardware(GenericHardware): def supported_power_interfaces(self): """List of supported power interfaces.""" return [fake.FakePower] + + @property + def supported_vendor_interfaces(self): + """List of supported vendor interfaces.""" + return [noop.NoVendor] diff --git a/ironic/drivers/hardware_type.py b/ironic/drivers/hardware_type.py index 001dfe8001..36b7880f19 100644 --- a/ironic/drivers/hardware_type.py +++ b/ironic/drivers/hardware_type.py @@ -20,6 +20,7 @@ import abc import six +from ironic.drivers import base as driver_base from ironic.drivers.modules.network import noop as noop_net from ironic.drivers.modules import noop from ironic.drivers.modules.storage import noop as noop_storage @@ -90,3 +91,24 @@ class AbstractHardwareType(object): def supported_vendor_interfaces(self): """List of supported vendor interfaces.""" return [noop.NoVendor] + + def get_properties(self): + """Get the properties of the hardware type. + + Note that this returns properties for the default interface of each + type, for this hardware type. Since this is not node-aware, + interface overrides can't be detected. + + :returns: dictionary of : entries. + """ + # NOTE(jroll) this avoids a circular import + from ironic.common import driver_factory + + properties = {} + for iface_type in driver_base.ALL_INTERFACES: + default_iface = driver_factory.default_interface(self, iface_type) + if default_iface is not None: + iface = driver_factory.get_interface(self, iface_type, + default_iface) + properties.update(iface.get_properties()) + return properties diff --git a/ironic/drivers/modules/noop.py b/ironic/drivers/modules/noop.py index b7c7eb98ca..2b9c255626 100644 --- a/ironic/drivers/modules/noop.py +++ b/ironic/drivers/modules/noop.py @@ -65,3 +65,6 @@ class NoInspect(FailMixin, base.InspectInterface): class NoRAID(FailMixin, base.RAIDInterface): """RAID interface implementation that raises errors on all requests.""" create_configuration = delete_configuration = _fail + + def validate_raid_config(self, task, raid_config): + _fail(self, task) diff --git a/ironic/tests/unit/api/v1/test_drivers.py b/ironic/tests/unit/api/v1/test_drivers.py index cc519c5690..7c753d40e2 100644 --- a/ironic/tests/unit/api/v1/test_drivers.py +++ b/ironic/tests/unit/api/v1/test_drivers.py @@ -24,65 +24,218 @@ from ironic.api.controllers import base as api_base from ironic.api.controllers.v1 import driver from ironic.common import exception from ironic.conductor import rpcapi +from ironic.drivers import base as driver_base from ironic.tests.unit.api import base class TestListDrivers(base.BaseApiTest): d1 = 'fake-driver1' d2 = 'fake-driver2' + d3 = 'fake-hardware-type' h1 = 'fake-host1' h2 = 'fake-host2' def register_fake_conductors(self): - self.dbapi.register_conductor({ + c1 = self.dbapi.register_conductor({ 'hostname': self.h1, 'drivers': [self.d1, self.d2], }) - self.dbapi.register_conductor({ + c2 = self.dbapi.register_conductor({ 'hostname': self.h2, 'drivers': [self.d2], }) + for c in (c1, c2): + self.dbapi.register_conductor_hardware_interfaces( + c.id, self.d3, 'deploy', ['iscsi', 'direct'], 'direct') - def test_drivers(self): + def _test_drivers(self, use_dynamic, detail=False): self.register_fake_conductors() - expected = sorted([ - {'name': self.d1, 'hosts': [self.h1]}, - {'name': self.d2, 'hosts': [self.h1, self.h2]}, - ], key=lambda d: d['name']) - data = self.get_json('/drivers') - self.assertThat(data['drivers'], matchers.HasLength(2)) + headers = {} + expected = [ + {'name': self.d1, 'hosts': [self.h1], 'type': 'classic'}, + {'name': self.d2, 'hosts': [self.h1, self.h2], 'type': 'classic'}, + {'name': self.d3, 'hosts': [self.h1, self.h2], 'type': 'dynamic'}, + ] + expected = sorted(expected, key=lambda d: d['name']) + if use_dynamic: + headers[api_base.Version.string] = '1.30' + + path = '/drivers' + if detail: + path += '?detail=True' + data = self.get_json(path, headers=headers) + + self.assertEqual(len(expected), len(data['drivers'])) drivers = sorted(data['drivers'], key=lambda d: d['name']) for i in range(len(expected)): d = drivers[i] - self.assertEqual(expected[i]['name'], d['name']) - self.assertEqual(sorted(expected[i]['hosts']), sorted(d['hosts'])) + e = expected[i] + + self.assertEqual(e['name'], d['name']) + self.assertEqual(sorted(e['hosts']), sorted(d['hosts'])) self.validate_link(d['links'][0]['href']) self.validate_link(d['links'][1]['href']) + if use_dynamic: + self.assertEqual(e['type'], d['type']) + + # NOTE(jroll) we don't test detail=True with use_dynamic=False + # as this case can't actually happen. + if detail: + self.assertIn('default_deploy_interface', d) + else: + # ensure we don't spill these fields into driver listing + # one should be enough + self.assertNotIn('default_deploy_interface', d) + + def test_drivers(self): + self._test_drivers(False) + + def test_drivers_with_dynamic(self): + self._test_drivers(True) + + def test_drivers_with_dynamic_detailed(self): + with mock.patch.object(self.dbapi, 'list_hardware_type_interfaces', + autospec=True) as mock_hw: + mock_hw.return_value = [ + { + 'hardware_type': self.d3, + 'interface_type': 'deploy', + 'interface_name': 'iscsi', + 'default': False, + }, + { + 'hardware_type': self.d3, + 'interface_type': 'deploy', + 'interface_name': 'direct', + 'default': True, + }, + ] + + self._test_drivers(True, detail=True) + + def _test_drivers_type_filter(self, requested_type): + self.register_fake_conductors() + headers = {api_base.Version.string: '1.30'} + data = self.get_json('/drivers?type=%s' % requested_type, + headers=headers) + for d in data['drivers']: + # just check it's the right type, other tests handle the rest + self.assertEqual(requested_type, d['type']) + + def test_drivers_type_filter_classic(self): + self._test_drivers_type_filter('classic') + + def test_drivers_type_filter_dynamic(self): + self._test_drivers_type_filter('dynamic') + + def test_drivers_type_filter_bad_version(self): + headers = {api_base.Version.string: '1.29'} + data = self.get_json('/drivers?type=classic', + headers=headers, + expect_errors=True) + self.assertEqual(http_client.NOT_ACCEPTABLE, data.status_code) + + def test_drivers_type_filter_bad_value(self): + headers = {api_base.Version.string: '1.30'} + data = self.get_json('/drivers?type=working', + headers=headers, + expect_errors=True) + self.assertEqual(http_client.BAD_REQUEST, data.status_code) + + def test_drivers_detail_bad_version(self): + headers = {api_base.Version.string: '1.29'} + data = self.get_json('/drivers?detail=True', + headers=headers, + expect_errors=True) + self.assertEqual(http_client.NOT_ACCEPTABLE, data.status_code) + + def test_drivers_detail_bad_version_false(self): + headers = {api_base.Version.string: '1.29'} + data = self.get_json('/drivers?detail=False', + headers=headers, + expect_errors=True) + self.assertEqual(http_client.NOT_ACCEPTABLE, data.status_code) + def test_drivers_no_active_conductor(self): data = self.get_json('/drivers') self.assertThat(data['drivers'], matchers.HasLength(0)) self.assertEqual([], data['drivers']) @mock.patch.object(rpcapi.ConductorAPI, 'get_driver_properties') - def test_drivers_get_one_ok(self, mock_driver_properties): + def _test_drivers_get_one_ok(self, use_dynamic, mock_driver_properties): # get_driver_properties mock is required by validate_link() self.register_fake_conductors() - data = self.get_json('/drivers/%s' % self.d1, - headers={api_base.Version.string: '1.14'}) - self.assertEqual(self.d1, data['name']) - self.assertEqual([self.h1], data['hosts']) - self.assertIn('properties', data.keys()) + + if use_dynamic: + driver = self.d3 + driver_type = 'dynamic' + hosts = [self.h1, self.h2] + else: + driver = self.d1 + driver_type = 'classic' + hosts = [self.h1] + + data = self.get_json('/drivers/%s' % driver, + headers={api_base.Version.string: '1.30'}) + + self.assertEqual(driver, data['name']) + self.assertEqual(sorted(hosts), sorted(data['hosts'])) + self.assertIn('properties', data) + self.assertEqual(driver_type, data['type']) + + if use_dynamic: + for iface in driver_base.ALL_INTERFACES: + # NOTE(jroll) we don't expose storage interface yet + if iface != 'storage': + self.assertIn('default_%s_interface' % iface, data) + self.assertIn('enabled_%s_interfaces' % iface, data) + self.assertIsNotNone(data['default_deploy_interface']) + self.assertIsNotNone(data['enabled_deploy_interfaces']) + else: + self.assertIsNone(data['default_deploy_interface']) + self.assertIsNone(data['enabled_deploy_interfaces']) + self.validate_link(data['links'][0]['href']) self.validate_link(data['links'][1]['href']) self.validate_link(data['properties'][0]['href']) self.validate_link(data['properties'][1]['href']) + def test_drivers_get_one_ok_classic(self): + self._test_drivers_get_one_ok(False) + + def test_drivers_get_one_ok_dynamic(self): + with mock.patch.object(self.dbapi, 'list_hardware_type_interfaces', + autospec=True) as mock_hw: + mock_hw.return_value = [ + { + 'hardware_type': self.d3, + 'interface_type': 'deploy', + 'interface_name': 'iscsi', + 'default': False, + }, + { + 'hardware_type': self.d3, + 'interface_type': 'deploy', + 'interface_name': 'direct', + 'default': True, + }, + ] + + self._test_drivers_get_one_ok(True) + mock_hw.assert_called_once_with([self.d3]) + def test_driver_properties_hidden_in_lower_version(self): self.register_fake_conductors() data = self.get_json('/drivers/%s' % self.d1, headers={api_base.Version.string: '1.8'}) - self.assertNotIn('properties', data.keys()) + self.assertNotIn('properties', data) + + def test_driver_type_hidden_in_lower_version(self): + self.register_fake_conductors() + data = self.get_json('/drivers/%s' % self.d1, + headers={api_base.Version.string: '1.14'}) + self.assertNotIn('type', data) def test_drivers_get_one_not_found(self): response = self.get_json('/drivers/%s' % self.d1, expect_errors=True) @@ -92,7 +245,7 @@ class TestListDrivers(base.BaseApiTest): cfg.CONF.set_override('public_endpoint', public_url, 'api') self.register_fake_conductors() data = self.get_json('/drivers/%s' % self.d1) - self.assertIn('links', data.keys()) + self.assertIn('links', data) self.assertEqual(2, len(data['links'])) self.assertIn(self.d1, data['links'][0]['href']) for l in data['links']: @@ -289,6 +442,25 @@ class TestDriverProperties(base.BaseApiTest): self.assertEqual(mock_properties.return_value, driver._DRIVER_PROPERTIES[driver_name]) + def test_driver_properties_hw_type(self, mock_topic, mock_properties): + # Can get driver properties for manual-management hardware type + driver._DRIVER_PROPERTIES = {} + driver_name = 'manual-management' + mock_topic.return_value = 'fake_topic' + mock_properties.return_value = {'prop1': 'Property 1. Required.'} + + with mock.patch.object(self.dbapi, 'get_active_hardware_type_dict', + autospec=True) as mock_hw_type: + mock_hw_type.return_value = {driver_name: 'fake_topic'} + data = self.get_json('/drivers/%s/properties' % driver_name) + + self.assertEqual(mock_properties.return_value, data) + mock_topic.assert_called_once_with(driver_name) + mock_properties.assert_called_once_with(mock.ANY, driver_name, + topic=mock_topic.return_value) + self.assertEqual(mock_properties.return_value, + driver._DRIVER_PROPERTIES[driver_name]) + def test_driver_properties_cached(self, mock_topic, mock_properties): # only one RPC-conductor call will be made and the info cached # for subsequent requests diff --git a/ironic/tests/unit/api/v1/test_utils.py b/ironic/tests/unit/api/v1/test_utils.py index 54862be031..6a2fa77032 100644 --- a/ironic/tests/unit/api/v1/test_utils.py +++ b/ironic/tests/unit/api/v1/test_utils.py @@ -221,6 +221,43 @@ class TestApiUtils(base.TestCase): self.assertRaises(exception.NotAcceptable, utils.check_allow_specify_resource_class, ['foo']) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_filter_driver_type(self, mock_request): + mock_request.version.minor = 30 + self.assertIsNone(utils.check_allow_filter_driver_type('classic')) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_filter_driver_type_none(self, mock_request): + mock_request.version.minor = 29 + self.assertIsNone(utils.check_allow_filter_driver_type(None)) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_filter_driver_type_fail(self, mock_request): + mock_request.version.minor = 29 + self.assertRaises(exception.NotAcceptable, + utils.check_allow_filter_driver_type, 'classic') + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_driver_detail(self, mock_request): + mock_request.version.minor = 30 + self.assertIsNone(utils.check_allow_driver_detail(True)) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_driver_detail_false(self, mock_request): + mock_request.version.minor = 30 + self.assertIsNone(utils.check_allow_driver_detail(False)) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_driver_detail_none(self, mock_request): + mock_request.version.minor = 29 + self.assertIsNone(utils.check_allow_driver_detail(None)) + + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_check_allow_driver_detail_fail(self, mock_request): + mock_request.version.minor = 29 + self.assertRaises(exception.NotAcceptable, + utils.check_allow_driver_detail, True) + @mock.patch.object(pecan, 'request', spec_set=['version']) def test_check_allow_manage_verbs(self, mock_request): mock_request.version.minor = 4 @@ -368,6 +405,13 @@ class TestApiUtils(base.TestCase): mock_request.version.minor = 25 self.assertFalse(utils.allow_portgroup_mode_properties()) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_allow_dynamic_drivers(self, mock_request): + mock_request.version.minor = 30 + self.assertTrue(utils.allow_dynamic_drivers()) + mock_request.version.minor = 29 + self.assertFalse(utils.allow_dynamic_drivers()) + class TestNodeIdent(base.TestCase): diff --git a/ironic/tests/unit/conductor/mgr_utils.py b/ironic/tests/unit/conductor/mgr_utils.py index 6564d32172..829c644de5 100644 --- a/ironic/tests/unit/conductor/mgr_utils.py +++ b/ironic/tests/unit/conductor/mgr_utils.py @@ -165,6 +165,18 @@ class ServiceSetUpMixin(object): self.config(enabled_drivers=['fake']) self.config(node_locked_retry_attempts=1, group='conductor') self.config(node_locked_retry_interval=0, group='conductor') + + self.config(enabled_hardware_types=['fake-hardware', + 'manual-management']) + self.config(enabled_boot_interfaces=['fake', 'pxe']) + self.config(enabled_console_interfaces=['fake', 'no-console']) + self.config(enabled_deploy_interfaces=['fake', 'iscsi']) + self.config(enabled_inspect_interfaces=['fake', 'no-inspect']) + self.config(enabled_management_interfaces=['fake']) + self.config(enabled_power_interfaces=['fake']) + self.config(enabled_raid_interfaces=['fake', 'no-raid']) + self.config(enabled_vendor_interfaces=['fake', 'no-vendor']) + self.service = manager.ConductorManager(self.hostname, 'test-topic') mock_the_extension_manager() self.driver = driver_factory.get_driver("fake") diff --git a/ironic/tests/unit/conductor/test_manager.py b/ironic/tests/unit/conductor/test_manager.py index 1b65fdc037..7998721504 100644 --- a/ironic/tests/unit/conductor/test_manager.py +++ b/ironic/tests/unit/conductor/test_manager.py @@ -607,13 +607,15 @@ class VendorPassthruTestCase(mgr_utils.ServiceSetUpMixin, @mock.patch.object(task_manager.TaskManager, 'upgrade_lock') @mock.patch.object(task_manager.TaskManager, 'spawn_after') - def test_vendor_passthru_async(self, mock_spawn, mock_upgrade): - node = obj_utils.create_test_node(self.context, driver='fake') + def _test_vendor_passthru_async(self, driver, vendor_iface, mock_spawn, + mock_upgrade): + node = obj_utils.create_test_node(self.context, driver=driver, + vendor_interface=vendor_iface) info = {'bar': 'baz'} self._start_service() response = self.service.vendor_passthru(self.context, node.uuid, - 'first_method', 'POST', + 'second_method', 'POST', info) # Waiting to make sure the below assertions are valid. self._stop_service() @@ -631,6 +633,13 @@ class VendorPassthruTestCase(mgr_utils.ServiceSetUpMixin, # Verify reservation has been cleared. self.assertIsNone(node.reservation) + def test_vendor_passthru_async(self): + self._test_vendor_passthru_async('fake', None) + + def test_vendor_passthru_async_hw_type(self): + self.config(enabled_vendor_interfaces=['fake']) + self._test_vendor_passthru_async('fake-hardware', 'fake') + @mock.patch.object(task_manager.TaskManager, 'upgrade_lock') @mock.patch.object(task_manager.TaskManager, 'spawn_after') def test_vendor_passthru_sync(self, mock_spawn, mock_upgrade): @@ -821,12 +830,20 @@ class VendorPassthruTestCase(mgr_utils.ServiceSetUpMixin, self.assertEqual(exception.UnsupportedDriverExtension, exc.exc_info[0]) + @mock.patch.object(driver_factory, 'get_interface') @mock.patch.object(manager.ConductorManager, '_spawn_worker') - def test_driver_vendor_passthru_sync(self, mock_spawn): + def _test_driver_vendor_passthru_sync(self, is_hw_type, mock_spawn, + mock_get_if): expected = {'foo': 'bar'} - self.driver.vendor = mock.Mock(spec=drivers_base.VendorInterface) + vendor_mock = mock.Mock(spec=drivers_base.VendorInterface) + if is_hw_type: + mock_get_if.return_value = vendor_mock + driver_name = 'fake-hardware' + else: + self.driver.vendor = vendor_mock + driver_name = 'fake' test_method = mock.MagicMock(return_value=expected) - self.driver.vendor.driver_routes = { + vendor_mock.driver_routes = { 'test_method': {'func': test_method, 'async': False, 'attach': False, @@ -834,19 +851,29 @@ class VendorPassthruTestCase(mgr_utils.ServiceSetUpMixin, self.service.init_host() # init_host() called _spawn_worker because of the heartbeat mock_spawn.reset_mock() + # init_host() called get_interface during driver loading + mock_get_if.reset_mock() vendor_args = {'test': 'arg'} response = self.service.driver_vendor_passthru( - self.context, 'fake', 'test_method', 'POST', vendor_args) + self.context, driver_name, 'test_method', 'POST', vendor_args) # Assert that the vendor interface has no custom # driver_vendor_passthru() - self.assertFalse(hasattr(self.driver.vendor, 'driver_vendor_passthru')) + self.assertFalse(hasattr(vendor_mock, 'driver_vendor_passthru')) self.assertEqual(expected, response['return']) self.assertFalse(response['async']) test_method.assert_called_once_with(self.context, **vendor_args) # No worker was spawned self.assertFalse(mock_spawn.called) + if is_hw_type: + mock_get_if.assert_called_once_with(mock.ANY, 'vendor', 'fake') + + def test_driver_vendor_passthru_sync(self): + self._test_driver_vendor_passthru_sync(False) + + def test_driver_vendor_passthru_sync_hw_type(self): + self._test_driver_vendor_passthru_sync(True) @mock.patch.object(manager.ConductorManager, '_spawn_worker') def test_driver_vendor_passthru_async(self, mock_spawn): @@ -920,21 +947,43 @@ class VendorPassthruTestCase(mgr_utils.ServiceSetUpMixin, self.context, 'does_not_exist', 'test_method', 'POST', {}) - def test_get_driver_vendor_passthru_methods(self): - self.driver.vendor = mock.Mock(spec=drivers_base.VendorInterface) + @mock.patch.object(driver_factory, 'get_interface') + def _test_get_driver_vendor_passthru_methods(self, is_hw_type, + mock_get_if): + vendor_mock = mock.Mock(spec=drivers_base.VendorInterface) + if is_hw_type: + mock_get_if.return_value = vendor_mock + driver_name = 'fake-hardware' + else: + self.driver.vendor = vendor_mock + driver_name = 'fake' fake_routes = {'test_method': {'async': True, 'description': 'foo', 'http_methods': ['POST'], 'func': None}} - self.driver.vendor.driver_routes = fake_routes + vendor_mock.driver_routes = fake_routes self.service.init_host() + # init_host() will call get_interface + mock_get_if.reset_mock() + data = self.service.get_driver_vendor_passthru_methods(self.context, - 'fake') + driver_name) # The function reference should not be returned del fake_routes['test_method']['func'] self.assertEqual(fake_routes, data) + if is_hw_type: + mock_get_if.assert_called_once_with(mock.ANY, 'vendor', 'fake') + else: + mock_get_if.assert_not_called() + + def test_get_driver_vendor_passthru_methods(self): + self._test_get_driver_vendor_passthru_methods(False) + + def test_get_driver_vendor_passthru_methods_hw_type(self): + self._test_get_driver_vendor_passthru_methods(True) + def test_get_driver_vendor_passthru_methods_not_supported(self): self.service.init_host() self.driver.vendor = None @@ -3720,15 +3769,20 @@ class UpdatePortgroupTestCase(mgr_utils.ServiceSetUpMixin, @mgr_utils.mock_record_keepalive class RaidTestCases(mgr_utils.ServiceSetUpMixin, tests_db_base.DbTestCase): + driver_name = 'fake' + raid_interface = None + def setUp(self): super(RaidTestCases, self).setUp() self.node = obj_utils.create_test_node( - self.context, driver='fake', provision_state=states.MANAGEABLE) + self.context, driver=self.driver_name, + raid_interface=self.raid_interface, + provision_state=states.MANAGEABLE) def test_get_raid_logical_disk_properties(self): self._start_service() properties = self.service.get_raid_logical_disk_properties( - self.context, 'fake') + self.context, self.driver_name) self.assertIn('raid_level', properties) self.assertIn('size_gb', properties) @@ -3737,7 +3791,7 @@ class RaidTestCases(mgr_utils.ServiceSetUpMixin, tests_db_base.DbTestCase): self._start_service() exc = self.assertRaises(messaging.rpc.ExpectedException, self.service.get_raid_logical_disk_properties, - self.context, 'fake') + self.context, self.driver_name) self.assertEqual(exception.UnsupportedDriverExtension, exc.exc_info[0]) def test_set_target_raid_config(self): @@ -3766,7 +3820,7 @@ class RaidTestCases(mgr_utils.ServiceSetUpMixin, tests_db_base.DbTestCase): self.node.refresh() self.assertEqual({}, self.node.target_raid_config) self.assertEqual(exception.UnsupportedDriverExtension, exc.exc_info[0]) - self.assertIn('fake', six.text_type(exc.exc_info[1])) + self.assertIn(self.driver_name, six.text_type(exc.exc_info[1])) def test_set_target_raid_config_invalid_parameter_value(self): # Missing raid_level in the below raid config. @@ -3784,6 +3838,42 @@ class RaidTestCases(mgr_utils.ServiceSetUpMixin, tests_db_base.DbTestCase): self.assertEqual(exception.InvalidParameterValue, exc.exc_info[0]) +@mgr_utils.mock_record_keepalive +class RaidHardwareTypeTestCases(RaidTestCases): + + driver_name = 'fake-hardware' + raid_interface = 'fake' + + def test_get_raid_logical_disk_properties_iface_not_supported(self): + self.config(enabled_raid_interfaces=[]) + self._start_service() + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.get_raid_logical_disk_properties, + self.context, self.driver_name) + self.assertEqual(exception.UnsupportedDriverExtension, exc.exc_info[0]) + + def test_set_target_raid_config_iface_not_supported(self): + # NOTE(jroll): it's impossible for a dynamic driver to have a null + # interface (e.g. node.driver.raid), so this instead tests that + # if validation fails, we blow up properly. + # need a different raid interface and a hardware type that supports it + self.node = obj_utils.create_test_node( + self.context, driver='manual-management', + raid_interface='no-raid', + uuid=uuidutils.generate_uuid(), + provision_state=states.MANAGEABLE) + raid_config = {'logical_disks': [{'size_gb': 100, 'raid_level': '1'}]} + + exc = self.assertRaises( + messaging.rpc.ExpectedException, + self.service.set_target_raid_config, + self.context, self.node.uuid, raid_config) + self.node.refresh() + self.assertEqual({}, self.node.target_raid_config) + self.assertEqual(exception.UnsupportedDriverExtension, exc.exc_info[0]) + self.assertIn('manual-management', six.text_type(exc.exc_info[1])) + + @mock.patch.object(conductor_utils, 'node_power_action') class ManagerDoSyncPowerStateTestCase(tests_db_base.DbTestCase): def setUp(self): @@ -4662,6 +4752,27 @@ class ManagerTestProperties(tests_db_base.DbTestCase): self.assertEqual(exception.DriverNotFound, exc.exc_info[0]) +@mgr_utils.mock_record_keepalive +class ManagerTestHardwareTypeProperties(tests_db_base.DbTestCase): + + def setUp(self): + super(ManagerTestHardwareTypeProperties, self).setUp() + self.service = manager.ConductorManager('test-host', 'test-topic') + + def _check_hardware_type_properties(self, hardware_type, expected): + self.config(enabled_hardware_types=[hardware_type]) + self.hardware_type = driver_factory.get_hardware_type(hardware_type) + self.service.init_host() + properties = self.service.get_driver_properties(self.context, + hardware_type) + self.assertEqual(sorted(expected), sorted(properties.keys())) + + def test_hardware_type_properties_manual_management(self): + expected = ['deploy_kernel', 'deploy_ramdisk', + 'deploy_forces_oob_reboot'] + self._check_hardware_type_properties('manual-management', expected) + + @mock.patch.object(task_manager, 'acquire') @mock.patch.object(manager.ConductorManager, '_mapped_to_this_conductor') @mock.patch.object(dbapi.IMPL, 'get_nodeinfo_list') diff --git a/ironic/tests/unit/db/test_conductor.py b/ironic/tests/unit/db/test_conductor.py index 76b065da51..62293f5c0f 100644 --- a/ironic/tests/unit/db/test_conductor.py +++ b/ironic/tests/unit/db/test_conductor.py @@ -385,3 +385,59 @@ class DbConductorTestCase(base.DbTestCase): # 61 seconds passed since last heartbeat, it's dead mock_utcnow.return_value = time_ + datetime.timedelta(seconds=61) self.assertEqual([c.hostname], self.dbapi.get_offline_conductors()) + + @mock.patch.object(timeutils, 'utcnow', autospec=True) + def test_list_hardware_type_interfaces(self, mock_utcnow): + self.config(heartbeat_timeout=60, group='conductor') + time_ = datetime.datetime(2000, 1, 1, 0, 0) + h = 'fake-host' + ht1 = 'hw-type-1' + ht2 = 'hw-type-2' + + mock_utcnow.return_value = time_ + self._create_test_cdr(hostname=h, hardware_types=[ht1, ht2]) + + expected = [ + { + 'hardware_type': ht1, + 'interface_type': 'power', + 'interface_name': 'ipmi', + 'default': True, + }, + { + 'hardware_type': ht1, + 'interface_type': 'power', + 'interface_name': 'fake', + 'default': False, + }, + { + 'hardware_type': ht2, + 'interface_type': 'power', + 'interface_name': 'ipmi', + 'default': True, + }, + { + 'hardware_type': ht2, + 'interface_type': 'power', + 'interface_name': 'fake', + 'default': False, + }, + ] + + def _verify(expected, result): + for expected_row, row in zip(expected, result): + for k, v in expected_row.items(): + self.assertEqual(v, getattr(row, k)) + + # with both hw types + result = self.dbapi.list_hardware_type_interfaces([ht1, ht2]) + _verify(expected, result) + + # with one hw type + result = self.dbapi.list_hardware_type_interfaces([ht1]) + _verify(expected[:2], result) + + # 61 seconds passed since last heartbeat, it's dead + mock_utcnow.return_value = time_ + datetime.timedelta(seconds=61) + result = self.dbapi.list_hardware_type_interfaces([ht1, ht2]) + self.assertEqual([], result) diff --git a/releasenotes/notes/dynamic-driver-list-show-apis-235e9fca26fc580d.yaml b/releasenotes/notes/dynamic-driver-list-show-apis-235e9fca26fc580d.yaml new file mode 100644 index 0000000000..cf0c23046b --- /dev/null +++ b/releasenotes/notes/dynamic-driver-list-show-apis-235e9fca26fc580d.yaml @@ -0,0 +1,20 @@ +--- +features: + - | + Provides support for dynamic drivers. + + With REST API version 1.30, adds additional parameters and response + fields for GET /v1/drivers and GET /v1/drivers/. + + Also allows dynamic drivers to be used and returned in the following + API calls, in all versions of the REST API: + + * GET /v1/drivers + * GET /v1/drivers/ + * GET /v1/drivers//properties + * GET /v1/drivers//vendor_passthru/methods + * GET/POST /v1/drivers//vendor_passthru + * GET/POST /v1/nodes//vendor_passthru + + For more details, see the `REST API Version History documentation + `_.