diff --git a/ironic/api/controllers/v1/driver.py b/ironic/api/controllers/v1/driver.py index 92329868be..740cd716e4 100644 --- a/ironic/api/controllers/v1/driver.py +++ b/ironic/api/controllers/v1/driver.py @@ -35,6 +35,16 @@ from ironic.common.i18n import _ # service should be restarted. _DRIVER_PROPERTIES = {} +# Vendor information for drivers: +# key = driver name; +# value = dictionary of vendor methods of that driver: +# key = method name. +# value = dictionary with the metadata of that method. +# NOTE(lucasagomes). This is cached for the lifetime of the API +# service. If one or more conductor services are restarted with new driver +# versions, the API service should be restarted. +_VENDOR_METHODS = {} + class Driver(base.APIBase): """API representation of a driver.""" @@ -100,6 +110,28 @@ class DriverPassthruController(rest.RestController): driver, no introspection will be made in the message body. """ + _custom_actions = { + 'methods': ['GET'] + } + + @wsme_pecan.wsexpose(wtypes.text, wtypes.text) + def methods(self, driver_name): + """Retrieve information about vendor methods of the given driver. + + :param driver_name: name of the driver. + :returns: dictionary with : + entries. + :raises: DriverNotFound if the driver name is invalid or the + driver cannot be loaded. + """ + if driver_name not in _VENDOR_METHODS: + topic = pecan.request.rpcapi.get_topic_for_driver(driver_name) + ret = pecan.request.rpcapi.get_driver_vendor_passthru_methods( + pecan.request.context, driver_name, topic=topic) + _VENDOR_METHODS[driver_name] = ret + + return _VENDOR_METHODS[driver_name] + @wsme_pecan.wsexpose(wtypes.text, wtypes.text, wtypes.text, body=wtypes.text) def _default(self, driver_name, method, data=None): diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index cbd5faaf14..77bd48bbb6 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -44,6 +44,16 @@ CONF.import_opt('heartbeat_timeout', 'ironic.conductor.manager', LOG = log.getLogger(__name__) +# Vendor information for node's driver: +# key = driver name; +# value = dictionary of node vendor methods of that driver: +# key = method name. +# value = dictionary with the metadata of that method. +# NOTE(lucasagomes). This is cached for the lifetime of the API +# service. If one or more conductor services are restarted with new driver +# versions, the API service should be restarted. +_VENDOR_METHODS = {} + class NodePatchType(types.JsonPatchType): @@ -552,6 +562,31 @@ class NodeVendorPassthruController(rest.RestController): appropriate driver, no introspection will be made in the message body. """ + _custom_actions = { + 'methods': ['GET'] + } + + @wsme_pecan.wsexpose(wtypes.text, types.uuid) + def methods(self, node_uuid): + """Retrieve information about vendor methods of the given node. + + :param node_uuid: UUID of a node. + :returns: dictionary with : + entries. + :raises: NodeNotFound if the node is not found. + """ + # Raise an exception if node is not found + rpc_node = objects.Node.get_by_uuid(pecan.request.context, + node_uuid) + + if rpc_node.driver not in _VENDOR_METHODS: + topic = pecan.request.rpcapi.get_topic_for(rpc_node) + ret = pecan.request.rpcapi.get_node_vendor_passthru_methods( + pecan.request.context, node_uuid, topic=topic) + _VENDOR_METHODS[rpc_node.driver] = ret + + return _VENDOR_METHODS[rpc_node.driver] + @wsme_pecan.wsexpose(wtypes.text, types.uuid, wtypes.text, body=wtypes.text) def _default(self, node_uuid, method, data=None): diff --git a/ironic/conductor/manager.py b/ironic/conductor/manager.py index ec24ce0cea..93edc53b5c 100644 --- a/ironic/conductor/manager.py +++ b/ironic/conductor/manager.py @@ -162,7 +162,7 @@ class ConductorManager(periodic_task.PeriodicTasks): """Ironic Conductor manager main class.""" # NOTE(rloo): This must be in sync with rpcapi.ConductorAPI's. - RPC_API_VERSION = '1.20' + RPC_API_VERSION = '1.21' target = messaging.Target(version=RPC_API_VERSION) @@ -398,7 +398,7 @@ class ConductorManager(periodic_task.PeriodicTasks): if not getattr(task.driver, 'vendor', None): raise exception.UnsupportedDriverExtension( driver=task.node.driver, - extension='vendor passthru') + extension='vendor interface') vendor_iface = task.driver.vendor @@ -544,6 +544,55 @@ class ConductorManager(periodic_task.PeriodicTasks): return (ret, is_async) + def _get_vendor_passthru_metadata(self, route_dict): + d = {} + for method, metadata in route_dict.iteritems(): + # 'func' is the vendor method reference, ignore it + d[method] = {k: metadata[k] for k in metadata if k != 'func'} + return d + + @messaging.expected_exceptions(exception.UnsupportedDriverExtension) + def get_node_vendor_passthru_methods(self, context, node_id): + """Retrieve information about vendor methods of the given node. + + :param context: an admin context. + :param node_id: the id or uuid of a node. + :returns: dictionary of : entries. + + """ + LOG.debug("RPC get_node_vendor_passthru_methods called for node %s" + % node_id) + with task_manager.acquire(context, node_id, shared=True) as task: + if not getattr(task.driver, 'vendor', None): + raise exception.UnsupportedDriverExtension( + driver=task.node.driver, + extension='vendor interface') + + return self._get_vendor_passthru_metadata( + task.driver.vendor.vendor_routes) + + @messaging.expected_exceptions(exception.UnsupportedDriverExtension, + exception.DriverNotFound) + def get_driver_vendor_passthru_methods(self, context, driver_name): + """Retrieve information about vendor methods of the given driver. + + :param context: an admin context. + :param driver_name: name of the driver. + :returns: dictionary of : entries. + + """ + # 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 get_driver_vendor_passthru_methods for driver %s" + % driver_name) + driver = self._get_driver(driver_name) + if not getattr(driver, 'vendor', None): + raise exception.UnsupportedDriverExtension( + driver=driver_name, + extension='vendor interface') + + return self._get_vendor_passthru_metadata(driver.vendor.driver_routes) + def _provisioning_error_handler(self, e, node, provision_state, target_provision_state): """Set the node's provisioning states if error occurs. diff --git a/ironic/conductor/rpcapi.py b/ironic/conductor/rpcapi.py index 1c8b50657d..68f7c469f6 100644 --- a/ironic/conductor/rpcapi.py +++ b/ironic/conductor/rpcapi.py @@ -59,14 +59,16 @@ class ConductorAPI(object): | get_supported_boot_devices. | 1.18 - Remove change_node_maintenance_mode. | 1.19 - Change return value of vendor_passthru and - driver_vendor_passthru + | driver_vendor_passthru | 1.20 - Added http_method parameter to vendor_passthru and - driver_vendor_passthru + | driver_vendor_passthru + | 1.21 - Added get_node_vendor_passthru_methods and + | get_driver_vendor_passthru_methods """ # NOTE(rloo): This must be in sync with manager.ConductorManager's. - RPC_API_VERSION = '1.20' + RPC_API_VERSION = '1.21' def __init__(self, topic=None): super(ConductorAPI, self).__init__() @@ -231,6 +233,33 @@ class ConductorAPI(object): http_method=http_method, info=info) + def get_node_vendor_passthru_methods(self, context, node_id, topic=None): + """Retrieve information about vendor methods of the given node. + + :param context: an admin context. + :param node_id: the id or uuid of a node. + :param topic: RPC topic. Defaults to self.topic. + :returns: dictionary of : entries. + + """ + cctxt = self.client.prepare(topic=topic or self.topic, version='1.21') + return cctxt.call(context, 'get_node_vendor_passthru_methods', + node_id=node_id) + + def get_driver_vendor_passthru_methods(self, context, driver_name, + topic=None): + """Retrieve information about vendor methods of the given driver. + + :param context: an admin context. + :param driver_name: name of the driver. + :param topic: RPC topic. Defaults to self.topic. + :returns: dictionary of : entries. + + """ + cctxt = self.client.prepare(topic=topic or self.topic, version='1.21') + return cctxt.call(context, 'get_driver_vendor_passthru_methods', + driver_name=driver_name) + def do_node_deploy(self, context, node_id, rebuild, topic=None): """Signal to conductor service to perform a deployment. diff --git a/ironic/drivers/base.py b/ironic/drivers/base.py index f005017bea..572ff90903 100644 --- a/ironic/drivers/base.py +++ b/ironic/drivers/base.py @@ -361,7 +361,8 @@ VendorMetadata = collections.namedtuple('VendorMetadata', ['method', 'metadata']) -def _passthru(http_methods, method=None, async=True, driver_passthru=False): +def _passthru(http_methods, method=None, async=True, driver_passthru=False, + description=None): """A decorator for registering a function as a passthru function. Decorator ensures function is ready to catch any ironic exceptions @@ -382,6 +383,7 @@ def _passthru(http_methods, method=None, async=True, driver_passthru=False): :param driver_passthru: Boolean value. True if this is a driver vendor passthru method, and False if it is a node vendor passthru method. + :param description: a string shortly describing what the method does. """ def handle_passthru(func): @@ -390,8 +392,10 @@ def _passthru(http_methods, method=None, async=True, driver_passthru=False): api_method = func.__name__ supported_ = [i.upper() for i in http_methods] + description_ = description or '' metadata = VendorMetadata(api_method, {'http_methods': supported_, - 'async': async}) + 'async': async, + 'description': description_}) if driver_passthru: func._driver_metadata = metadata else: @@ -414,12 +418,14 @@ def _passthru(http_methods, method=None, async=True, driver_passthru=False): return handle_passthru -def passthru(http_methods, method=None, async=True): - return _passthru(http_methods, method, async, driver_passthru=False) +def passthru(http_methods, method=None, async=True, description=None): + return _passthru(http_methods, method, async, driver_passthru=False, + description=description) -def driver_passthru(http_methods, method=None, async=True): - return _passthru(http_methods, method, async, driver_passthru=True) +def driver_passthru(http_methods, method=None, async=True, description=None): + return _passthru(http_methods, method, async, driver_passthru=True, + description=description) @six.add_metaclass(abc.ABCMeta) diff --git a/ironic/drivers/modules/fake.py b/ironic/drivers/modules/fake.py index e07680d725..42fc9e0605 100644 --- a/ironic/drivers/modules/fake.py +++ b/ironic/drivers/modules/fake.py @@ -108,7 +108,8 @@ class FakeVendorA(base.VendorInterface): return _raise_unsupported_error(method) - @base.passthru(['POST']) + @base.passthru(['POST'], + description=_("Test if the value of bar is baz")) def first_method(self, task, http_method, bar): return True if bar == 'baz' else False @@ -130,11 +131,13 @@ class FakeVendorB(base.VendorInterface): return _raise_unsupported_error(method) - @base.passthru(['POST']) + @base.passthru(['POST'], + description=_("Test if the value of bar is kazoo")) def second_method(self, task, http_method, bar): return True if bar == 'kazoo' else False - @base.passthru(['POST'], async=False) + @base.passthru(['POST'], async=False, + description=_("Test if the value of bar is meow")) def third_method_sync(self, task, http_method, bar): return True if bar == 'meow' else False diff --git a/ironic/tests/api/v1/test_drivers.py b/ironic/tests/api/v1/test_drivers.py index 4402a7d5c5..e8599ad22d 100644 --- a/ironic/tests/api/v1/test_drivers.py +++ b/ironic/tests/api/v1/test_drivers.py @@ -147,6 +147,28 @@ class TestListDrivers(base.FunctionalTest): self.assertEqual('Missing argument: "method"', error['faultstring']) + @mock.patch.object(rpcapi.ConductorAPI, + 'get_driver_vendor_passthru_methods') + def test_driver_vendor_passthru_methods(self, get_methods_mock): + self.register_fake_conductors() + return_value = {'foo': 'bar'} + get_methods_mock.return_value = return_value + path = '/drivers/%s/vendor_passthru/methods' % self.d1 + + data = self.get_json(path) + self.assertEqual(return_value, data) + get_methods_mock.assert_called_once_with(mock.ANY, self.d1, + topic=mock.ANY) + + # Now let's test the cache: Reset the mock + get_methods_mock.reset_mock() + + # Call it again + data = self.get_json(path) + self.assertEqual(return_value, data) + # Assert RPC method wasn't called this time + self.assertFalse(get_methods_mock.called) + @mock.patch.object(rpcapi.ConductorAPI, 'get_driver_properties') @mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for_driver') diff --git a/ironic/tests/api/v1/test_nodes.py b/ironic/tests/api/v1/test_nodes.py index 9e8fb0e1a7..155a7e785e 100644 --- a/ironic/tests/api/v1/test_nodes.py +++ b/ironic/tests/api/v1/test_nodes.py @@ -968,6 +968,27 @@ class TestPost(api_base.FunctionalTest): self.assertEqual(400, response.status_int) self.assertTrue(response.json['error_message']) + @mock.patch.object(rpcapi.ConductorAPI, 'get_node_vendor_passthru_methods') + def test_vendor_passthru_methods(self, get_methods_mock): + return_value = {'foo': 'bar'} + get_methods_mock.return_value = return_value + node = obj_utils.create_test_node(self.context) + path = '/nodes/%s/vendor_passthru/methods' % node.uuid + + data = self.get_json(path) + self.assertEqual(return_value, data) + get_methods_mock.assert_called_once_with(mock.ANY, node.uuid, + topic=mock.ANY) + + # Now let's test the cache: Reset the mock + get_methods_mock.reset_mock() + + # Call it again + data = self.get_json(path) + self.assertEqual(return_value, data) + # Assert RPC method wasn't called this time + self.assertFalse(get_methods_mock.called) + class TestDelete(api_base.FunctionalTest): diff --git a/ironic/tests/conductor/test_manager.py b/ironic/tests/conductor/test_manager.py index c185172c65..e7685ba183 100644 --- a/ironic/tests/conductor/test_manager.py +++ b/ironic/tests/conductor/test_manager.py @@ -676,6 +676,31 @@ class VendorPassthruTestCase(_ServiceSetUpMixin, tests_db_base.DbTestCase): task.spawn_after.assert_called_once_with(mock.ANY, vendor_passthru_ref, task, bar='baz', method='test_method') + def test_get_node_vendor_passthru_methods(self): + node = obj_utils.create_test_node(self.context, driver='fake') + fake_routes = {'test_method': {'async': True, + 'description': 'foo', + 'http_methods': ['POST'], + 'func': None}} + self.driver.vendor.vendor_routes = fake_routes + self._start_service() + + data = self.service.get_node_vendor_passthru_methods(self.context, + node.uuid) + # The function reference should not be returned + del fake_routes['test_method']['func'] + self.assertEqual(fake_routes, data) + + def test_get_node_vendor_passthru_methods_not_supported(self): + node = obj_utils.create_test_node(self.context, driver='fake') + self.driver.vendor = None + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.get_node_vendor_passthru_methods, + self.context, node.uuid) + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.UnsupportedDriverExtension, + exc.exc_info[0]) + @mock.patch.object(manager.ConductorManager, '_spawn_worker') def test_driver_vendor_passthru_sync(self, mock_spawn): expected = {'foo': 'bar'} @@ -791,6 +816,31 @@ class VendorPassthruTestCase(_ServiceSetUpMixin, tests_db_base.DbTestCase): driver_vendor_passthru_ref.assert_called_once_with( self.context, test='arg', method='test_method') + def test_get_driver_vendor_passthru_methods(self): + self.driver.vendor = mock.Mock(spec=drivers_base.VendorInterface) + fake_routes = {'test_method': {'async': True, + 'description': 'foo', + 'http_methods': ['POST'], + 'func': None}} + self.driver.vendor.driver_routes = fake_routes + self.service.init_host() + + data = self.service.get_driver_vendor_passthru_methods(self.context, + 'fake') + # The function reference should not be returned + del fake_routes['test_method']['func'] + self.assertEqual(fake_routes, data) + + def test_get_driver_vendor_passthru_methods_not_supported(self): + self.service.init_host() + self.driver.vendor = None + exc = self.assertRaises(messaging.rpc.ExpectedException, + self.service.get_driver_vendor_passthru_methods, + self.context, 'fake') + # Compare true exception hidden by @messaging.expected_exceptions + self.assertEqual(exception.UnsupportedDriverExtension, + exc.exc_info[0]) + @_mock_record_keepalive class DoNodeDeployTearDownTestCase(_ServiceSetUpMixin, diff --git a/ironic/tests/conductor/test_rpcapi.py b/ironic/tests/conductor/test_rpcapi.py index 1b15997473..c177ec70a7 100644 --- a/ironic/tests/conductor/test_rpcapi.py +++ b/ironic/tests/conductor/test_rpcapi.py @@ -269,3 +269,15 @@ class RPCAPITestCase(base.DbTestCase): 'call', version='1.17', node_id=self.fake_node['uuid']) + + def test_get_node_vendor_passthru_methods(self): + self._test_rpcapi('get_node_vendor_passthru_methods', + 'call', + version='1.21', + node_id=self.fake_node['uuid']) + + def test_get_driver_vendor_passthru_methods(self): + self._test_rpcapi('get_driver_vendor_passthru_methods', + 'call', + version='1.21', + driver_name='fake-driver')