Make vendor methods discoverable via the Ironic API

The @passthru and @driver_passthru decorators were extended to support
adding a description about the method being decorated which will also be
returned by the API. That said two new endpoints to the API that returns
the available methods for the given driver or node.

* GET /v1/drivers/<driver name>/vendor_passthru/methods

* GET /v1/nodes/<node uuid>/vendor_passthru/methods

Implements: blueprint extended-vendor-passthru
Change-Id: Idac6a433da1a9ae170dbe3d8ceb9e4c76fcdb1be
This commit is contained in:
Lucas Alvares Gomes 2014-11-10 16:03:01 +00:00
parent 6e2f985b64
commit e69e53097a
10 changed files with 273 additions and 14 deletions

View File

@ -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 <vendor method name>:<method metadata>
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):

View File

@ -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 <vendor method name>:<method metadata>
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):

View File

@ -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 <method name>:<method metadata> 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 <method name>:<method metadata> 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.

View File

@ -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 <method name>:<method metadata> 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 <method name>:<method metadata> 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.

View File

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

View File

@ -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

View File

@ -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')

View File

@ -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):

View File

@ -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,

View File

@ -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')