diff --git a/doc/source/webapi/v1.rst b/doc/source/webapi/v1.rst index d40f591dc4..506aa1acb1 100644 --- a/doc/source/webapi/v1.rst +++ b/doc/source/webapi/v1.rst @@ -32,6 +32,12 @@ always requests the newest supported API version. API Versions History -------------------- +**1.14** + + Make the following endpoints discoverable via Ironic API: + * '/v1/nodes//states' + * '/v1/drivers//properties' + **1.13** Add a new verb ``abort`` to the API used to abort nodes in diff --git a/ironic/api/controllers/v1/driver.py b/ironic/api/controllers/v1/driver.py index 74299028ba..f84c1aad29 100644 --- a/ironic/api/controllers/v1/driver.py +++ b/ironic/api/controllers/v1/driver.py @@ -70,6 +70,9 @@ class Driver(base.APIBase): 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""" + @staticmethod def convert_with_links(name, hosts): driver = Driver() @@ -84,6 +87,16 @@ class Driver(base.APIBase): 'drivers', name, bookmark=True) ] + if api_utils.allow_links_node_states_and_driver_properties(): + driver.properties = [ + link.Link.make_link('self', + pecan.request.public_url, + 'drivers', name + "/properties"), + link.Link.make_link('bookmark', + pecan.request.public_url, + 'drivers', name + "/properties", + bookmark=True) + ] return driver @classmethod diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 664016b9c3..d95298f712 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -625,6 +625,9 @@ class Node(base.APIBase): ports = wsme.wsattr([link.Link], readonly=True) """Links to the collection of ports on this node""" + states = wsme.wsattr([link.Link], readonly=True) + """Links to endpoint for retrieving and setting node states""" + # NOTE(deva): "conductor_affinity" shouldn't be presented on the # API because it's an internal value. Don't add it here. @@ -648,7 +651,8 @@ class Node(base.APIBase): setattr(self, 'chassis_uuid', kwargs.get('chassis_id', wtypes.Unset)) @staticmethod - def _convert_with_links(node, url, fields=None, show_password=True): + def _convert_with_links(node, url, fields=None, show_password=True, + show_states_links=True): # NOTE(lucasagomes): Since we are able to return a specified set of # fields the "uuid" can be unset, so we need to save it in another # variable to use when building the links @@ -662,6 +666,12 @@ class Node(base.APIBase): node_uuid + "/ports", bookmark=True) ] + if show_states_links: + node.states = [link.Link.make_link('self', url, 'nodes', + node_uuid + "/states"), + link.Link.make_link('bookmark', url, 'nodes', + node_uuid + "/states", + bookmark=True)] if not show_password and node.driver_info != wtypes.Unset: node.driver_info = ast.literal_eval(strutils.mask_password( @@ -689,9 +699,12 @@ class Node(base.APIBase): assert_juno_provision_state_name(node) hide_fields_in_newer_versions(node) show_password = pecan.request.context.show_password + show_states_links = ( + api_utils.allow_links_node_states_and_driver_properties()) return cls._convert_with_links(node, pecan.request.public_url, fields=fields, - show_password=show_password) + show_password=show_password, + show_states_links=show_states_links) @classmethod def sample(cls, expand=True): diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 323e1b66c8..538ca4540d 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -231,3 +231,13 @@ def allow_raid_config(): Version 1.12 of the API allows RAID configuration for the node. """ return pecan.request.version.minor >= versions.MINOR_12_RAID_CONFIG + + +def allow_links_node_states_and_driver_properties(): + """Check if links are displayable. + + Version 1.14 of the API allows the display of links to node states + and driver properties. + """ + return (pecan.request.version.minor >= + versions.MINOR_14_LINKS_NODESTATES_DRIVERPROPERTIES) diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 4ca3ddf66d..a9367d3fff 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -41,6 +41,9 @@ BASE_VERSION = 1 # v1.11: Nodes appear in ENROLL state by default # v1.12: Add support for RAID # v1.13: Add 'abort' verb to CLEANWAIT +# v1.14: Make the following endpoints discoverable via API: +# 1. '/v1/nodes//states' +# 2. '/v1/drivers//properties' MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 @@ -56,11 +59,12 @@ MINOR_10_UNRESTRICTED_NODE_NAME = 10 MINOR_11_ENROLL_STATE = 11 MINOR_12_RAID_CONFIG = 12 MINOR_13_ABORT_VERB = 13 +MINOR_14_LINKS_NODESTATES_DRIVERPROPERTIES = 14 # When adding another version, update MINOR_MAX_VERSION and also update # doc/source/webapi/v1.rst with a detailed explanation of what the version has # changed. -MINOR_MAX_VERSION = MINOR_13_ABORT_VERB +MINOR_MAX_VERSION = MINOR_14_LINKS_NODESTATES_DRIVERPROPERTIES # String representations of the minor and maximum versions MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/tests/api/v1/test_drivers.py b/ironic/tests/api/v1/test_drivers.py index 989c6a71a5..5e548a0632 100644 --- a/ironic/tests/api/v1/test_drivers.py +++ b/ironic/tests/api/v1/test_drivers.py @@ -64,13 +64,25 @@ class TestListDrivers(base.FunctionalTest): self.assertThat(data['drivers'], HasLength(0)) self.assertEqual([], data['drivers']) - def test_drivers_get_one_ok(self): + @mock.patch.object(rpcapi.ConductorAPI, 'get_driver_properties') + def test_drivers_get_one_ok(self, mock_driver_properties): + # get_driver_properties mock is required by validate_link() self.register_fake_conductors() - data = self.get_json('/drivers/%s' % self.d1) + 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()) 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_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()) def test_drivers_get_one_not_found(self): response = self.get_json('/drivers/%s' % self.d1, expect_errors=True) diff --git a/ironic/tests/api/v1/test_nodes.py b/ironic/tests/api/v1/test_nodes.py index de09e5b42a..37a6411236 100644 --- a/ironic/tests/api/v1/test_nodes.py +++ b/ironic/tests/api/v1/test_nodes.py @@ -134,9 +134,18 @@ class TestListNodes(test_api_base.FunctionalTest): self.assertIn('inspection_finished_at', data) self.assertIn('inspection_started_at', data) self.assertIn('clean_step', data) + self.assertIn('states', data) # never expose the chassis_id self.assertNotIn('chassis_id', data) + def test_node_states_field_hidden_in_lower_version(self): + node = obj_utils.create_test_node(self.context, + chassis_id=self.chassis.id) + data = self.get_json( + '/nodes/%s' % node.uuid, + headers={api_base.Version.string: '1.8'}) + self.assertNotIn('states', data) + def test_get_one_custom_fields(self): node = obj_utils.create_test_node(self.context, chassis_id=self.chassis.id) diff --git a/ironic/tests/api/v1/test_utils.py b/ironic/tests/api/v1/test_utils.py index 079307641a..f8c0f417f8 100644 --- a/ironic/tests/api/v1/test_utils.py +++ b/ironic/tests/api/v1/test_utils.py @@ -79,6 +79,13 @@ class TestApiUtils(base.TestCase): self.assertRaises(exception.NotAcceptable, utils.check_allow_specify_fields, ['foo']) + @mock.patch.object(pecan, 'request', spec_set=['version']) + def test_allow_links_node_states_and_driver_properties(self, mock_request): + mock_request.version.minor = 14 + self.assertTrue(utils.allow_links_node_states_and_driver_properties()) + mock_request.version.minor = 10 + self.assertFalse(utils.allow_links_node_states_and_driver_properties()) + class TestNodeIdent(base.TestCase):