diff --git a/api-ref/source/baremetal-api-v1-nodes-inventory.inc b/api-ref/source/baremetal-api-v1-nodes-inventory.inc new file mode 100644 index 0000000000..4c36e5aa24 --- /dev/null +++ b/api-ref/source/baremetal-api-v1-nodes-inventory.inc @@ -0,0 +1,40 @@ +.. -*- rst -*- + +============== +Node inventory +============== + +.. versionadded:: 1.81 + +Given a Node identifier, the API provides access to the introspection data +associated to the Node via ``v1/nodes/{node_ident}/inventory`` endpoint. + +Fetch node inventory +=============================== + +.. rest_method:: GET /v1/nodes/{node_ident}/inventory + +Normal response code: 200 + +Error codes: + - 404 (NodeNotFound, InventoryNotRecorded) + +Request +------- + +.. rest_parameters:: parameters.yaml + + - node_ident: node_ident + +Response +-------- + +.. rest_parameters:: parameters.yaml + + - inventory: n_inventory + - plugin_data: n_plugin_data + +**Example of inventory from a node:** + +.. literalinclude:: samples/node-inventory-response.json + :language: javascript diff --git a/api-ref/source/parameters.yaml b/api-ref/source/parameters.yaml index d0da64ec24..b55ef405f2 100644 --- a/api-ref/source/parameters.yaml +++ b/api-ref/source/parameters.yaml @@ -1191,6 +1191,18 @@ n_indicators: in: body required: true type: array +n_inventory: + description: | + Inventory of this node. + in: body + required: false + type: JSON +n_plugin_data: + description: | + Plugin data of this node. + in: body + required: false + type: JSON n_portgroups: description: | Links to the collection of portgroups on this node. diff --git a/api-ref/source/samples/node-inventory-response.json b/api-ref/source/samples/node-inventory-response.json new file mode 100644 index 0000000000..7916f67173 --- /dev/null +++ b/api-ref/source/samples/node-inventory-response.json @@ -0,0 +1,31 @@ +{ + "inventory": { + "interfaces":[ + { + "name":"eth0", + "mac_address":"52:54:00:90:35:d6", + "ipv4_address":"192.168.122.128", + "ipv6_address":"fe80::5054:ff:fe90:35d6%eth0", + "has_carrier":true, + "lldp":null, + "vendor":"0x1af4", + "product":"0x0001" + } + ], + "cpu":{ + "model_name":"QEMU Virtual CPU version 2.5+", + "frequency":null, + "count":1, + "architecture":"x86_64" + } + }, + "plugin_data":{ + "macs":[ + "52:54:00:90:35:d6" + ], + "local_gb":10, + "cpus":1, + "cpu_arch":"x86_64", + "memory_mb":2048 + } +} diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index 074106bc83..51b4a8d039 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -2,6 +2,13 @@ REST API Version History ======================== +1.81 (Antelope) +---------------------- + +Add endpoint to retrieve introspection data for nodes via the REST API. + +* ``GET /v1/nodes/{node_ident}/inventory/`` + 1.80 (Zed, 21.1) ---------------------- diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py index 59b166db4f..fc6d70481f 100644 --- a/ironic/api/controllers/v1/node.py +++ b/ironic/api/controllers/v1/node.py @@ -48,6 +48,7 @@ from ironic.common import states as ir_states from ironic.conductor import steps as conductor_steps import ironic.conf from ironic.drivers import base as driver_base +from ironic.drivers.modules import inspector as inspector from ironic import objects @@ -1944,6 +1945,39 @@ class NodeHistoryController(rest.RestController): node.uuid, event, detail=True) +class NodeInventoryController(rest.RestController): + + def __init__(self, node_ident): + super(NodeInventoryController).__init__() + self.node_ident = node_ident + + def _node_inventory_convert(self, node_inventory): + inventory_data = node_inventory['inventory_data'] + plugin_data = node_inventory['plugin_data'] + return {"inventory": inventory_data, "plugin_data": plugin_data} + + @METRICS.timer('NodeInventoryController.get') + @method.expose() + @args.validate(node_ident=args.uuid_or_name) + def get(self): + """Node inventory of the node. + + :param node_ident: the UUID of a node. + """ + node = api_utils.check_node_policy_and_retrieve( + 'baremetal:node:inventory:get', self.node_ident) + store_data = CONF.inspector.inventory_data_backend + if store_data == 'none': + raise exception.NotFound( + (_("Cannot obtain node inventory because it was not stored"))) + if store_data == 'database': + node_inventory = objects.NodeInventory.get_by_node_id( + api.request.context, node.id) + return self._node_inventory_convert(node_inventory) + if store_data == 'swift': + return inspector.get_introspection_data(node.uuid) + + class NodesController(rest.RestController): """REST controller for Nodes.""" @@ -1990,6 +2024,7 @@ class NodesController(rest.RestController): 'bios': bios.NodeBiosController, 'allocation': allocation.NodeAllocationController, 'history': NodeHistoryController, + 'inventory': NodeInventoryController, } @pecan.expose() @@ -2013,7 +2048,9 @@ class NodesController(rest.RestController): or (remainder[0] == 'allocation' and not api_utils.allow_allocations()) or (remainder[0] == 'history' - and not api_utils.allow_node_history())): + and not api_utils.allow_node_history()) + or (remainder[0] == 'inventory' + and not api_utils.allow_node_inventory())): pecan.abort(http_client.NOT_FOUND) if remainder[0] == 'traits' and not api_utils.allow_traits(): # NOTE(mgoddard): Returning here will ensure we exhibit the diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index 8de2d156df..0494077cc4 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -1341,6 +1341,11 @@ def allow_node_history(): return api.request.version.minor >= versions.MINOR_78_NODE_HISTORY +def allow_node_inventory(): + """Check if node inventory is allowed.""" + return api.request.version.minor >= versions.MINOR_81_NODE_INVENTORY + + def get_request_return_fields(fields, detail, default_fields, check_detail_version=allow_detail_query, check_fields_version=None): diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 763d923897..4dcfd7fb8e 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -118,6 +118,7 @@ BASE_VERSION = 1 # v1.78: Add node history endpoint # v1.79: Change allocation behaviour to prefer node name match # v1.80: Marker to represent self service node creation/deletion +# v1.81: Add node inventory MINOR_0_JUNO = 0 MINOR_1_INITIAL_VERSION = 1 MINOR_2_AVAILABLE_STATE = 2 @@ -199,6 +200,7 @@ MINOR_77_DRIVER_FIELDS_SELECTOR = 77 MINOR_78_NODE_HISTORY = 78 MINOR_79_ALLOCATION_NODE_NAME = 79 MINOR_80_PROJECT_CREATE_DELETE_NODE = 80 +MINOR_81_NODE_INVENTORY = 81 # When adding another version, update: # - MINOR_MAX_VERSION @@ -206,7 +208,7 @@ MINOR_80_PROJECT_CREATE_DELETE_NODE = 80 # explanation of what changed in the new version # - common/release_mappings.py, RELEASE_MAPPING['master']['api'] -MINOR_MAX_VERSION = MINOR_80_PROJECT_CREATE_DELETE_NODE +MINOR_MAX_VERSION = MINOR_81_NODE_INVENTORY # String representations of the minor and maximum versions _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) diff --git a/ironic/common/policy.py b/ironic/common/policy.py index 7fdd398f99..afce51c77d 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -954,6 +954,19 @@ node_policies = [ # operating context. deprecated_rule=deprecated_node_get ), + policy.DocumentedRuleDefault( + name='baremetal:node:inventory:get', + check_str=SYSTEM_OR_OWNER_READER, + scope_types=['system', 'project'], + description='Retrieve introspection data for a node.', + operations=[ + {'path': '/nodes/{node_ident}/inventory', 'method': 'GET'}, + ], + # This rule fallsback to deprecated_node_get in order to provide a + # mechanism so the additional policies only engage in an updated + # operating context. + deprecated_rule=deprecated_node_get + ), ] diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 555ff164d1..1cd7ac17ca 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -511,7 +511,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.80', + 'api': '1.81', 'rpc': '1.55', 'objects': { 'Allocation': ['1.1'], diff --git a/ironic/common/swift.py b/ironic/common/swift.py index dde94fb187..87cda4fade 100644 --- a/ironic/common/swift.py +++ b/ironic/common/swift.py @@ -168,6 +168,23 @@ class SwiftAPI(object): (parse_result.scheme, parse_result.netloc, url_path, None, None, None)) + def get_object(self, object, container): + """Downloads a given object from Swift. + + :param object: The name of the object in Swift + :param container: The name of the container for the object. + Defaults to the value set in the configuration options. + :returns: Swift object + :raises: utils.Error, if the Swift operation fails. + """ + try: + obj = self.connection.download_object(object, container=container) + except swift_exceptions.ClientException as e: + operation = _("get object") + raise exception.SwiftOperationError(operation=operation, error=e) + + return obj + def delete_object(self, container, obj): """Deletes the given Swift object. diff --git a/ironic/drivers/modules/inspector.py b/ironic/drivers/modules/inspector.py index 20911cbaaf..a4c8c10919 100644 --- a/ironic/drivers/modules/inspector.py +++ b/ironic/drivers/modules/inspector.py @@ -425,3 +425,20 @@ def store_introspection_data(node_uuid, inventory_data, plugin_data): plugin_data, container) return swift_object_name + + +def get_introspection_data(node_uuid): + """Uploads introspection data to Swift. + + :param data: data to store in Swift + :param node_id: ID of the Ironic node that the data came from + :returns: name of the Swift object that the data is stored in + """ + swift_api = swift.SwiftAPI() + swift_object_name = '%s-%s' % (_OBJECT_NAME_PREFIX, node_uuid) + container = CONF.inspector.swift_inventory_data_container + inventory_data = swift_api.get_object(swift_object_name + '-inventory', + container) + plugin_data = swift_api.get_object(swift_object_name + '-plugin', + container) + return {"inventory": inventory_data, "plugin_data": plugin_data} diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py index 6531f36e70..2f880db7d6 100644 --- a/ironic/tests/unit/api/controllers/v1/test_node.py +++ b/ironic/tests/unit/api/controllers/v1/test_node.py @@ -43,6 +43,7 @@ from ironic.common import indicator_states from ironic.common import policy from ironic.common import states from ironic.conductor import rpcapi +from ironic.drivers.modules import inspector from ironic import objects from ironic.objects import fields as obj_fields from ironic import tests as tests_root @@ -51,6 +52,7 @@ from ironic.tests.unit.api import base as test_api_base from ironic.tests.unit.api import utils as test_api_utils from ironic.tests.unit.objects import utils as obj_utils +CONF = inspector.CONF with open( os.path.join( @@ -7912,3 +7914,55 @@ class TestNodeHistory(test_api_base.BaseApiTest): self.assertIn('nodes/%s/history' % self.node.uuid, ret['next']) self.assertIn('limit=1', ret['next']) self.assertIn('marker=%s' % result_uuid, ret['next']) + + +class TestNodeInventory(test_api_base.BaseApiTest): + fake_inventory_data = {"cpu": "amd"} + fake_plugin_data = {"disks": [{"name": "/dev/vda"}]} + + def setUp(self): + super(TestNodeInventory, self).setUp() + self.version = "1.81" + self.node = obj_utils.create_test_node( + self.context, + provision_state=states.AVAILABLE, name='node-81') + self.node.save() + self.node.obj_reset_changes() + + def _add_inventory(self): + self.inventory = objects.NodeInventory( + node_id=self.node.id, inventory_data=self.fake_inventory_data, + plugin_data=self.fake_plugin_data) + self.inventory.create() + + def test_get_old_version(self): + ret = self.get_json('/nodes/%s/inventory' % self.node.uuid, + headers={api_base.Version.string: "1.80"}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, ret.status_code) + + def test_get_inventory_no_inventory(self): + ret = self.get_json('/nodes/%s/inventory' % self.node.uuid, + headers={api_base.Version.string: self.version}, + expect_errors=True) + self.assertEqual(http_client.NOT_FOUND, ret.status_code) + + def test_get_inventory(self): + self._add_inventory() + CONF.set_override('inventory_data_backend', 'database', + group='inspector') + ret = self.get_json('/nodes/%s/inventory' % self.node.uuid, + headers={api_base.Version.string: self.version}) + self.assertEqual({'inventory': self.fake_inventory_data, + 'plugin_data': self.fake_plugin_data}, ret) + + @mock.patch.object(inspector, 'get_introspection_data', autospec=True) + def test_get_inventory_swift(self, mock_get_data): + CONF.set_override('inventory_data_backend', 'swift', + group='inspector') + mock_get_data.return_value = {"inventory": self.fake_inventory_data, + "plugin_data": self.fake_plugin_data} + ret = self.get_json('/nodes/%s/inventory' % self.node.uuid, + headers={api_base.Version.string: self.version}) + self.assertEqual({'inventory': self.fake_inventory_data, + 'plugin_data': self.fake_plugin_data}, ret) diff --git a/ironic/tests/unit/api/test_acl.py b/ironic/tests/unit/api/test_acl.py index cdc20d4770..f5cbe498d7 100644 --- a/ironic/tests/unit/api/test_acl.py +++ b/ironic/tests/unit/api/test_acl.py @@ -286,6 +286,8 @@ class TestRBACModelBeforeScopesBase(TestACLBase): db_utils.create_test_node_trait( node_id=fake_db_node['id']) fake_history = db_utils.create_test_history(node_id=fake_db_node.id) + fake_inventory = db_utils.create_test_inventory( + node_id=fake_db_node.id) # dedicated node for portgroup addition test to avoid # false positives with test runners. db_utils.create_test_node( @@ -309,6 +311,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase): 'volume_target_ident': fake_db_volume_target['uuid'], 'volume_connector_ident': fake_db_volume_connector['uuid'], 'history_ident': fake_history['uuid'], + 'node_inventory': fake_inventory, }) @@ -415,6 +418,8 @@ class TestRBACProjectScoped(TestACLBase): resource_class="CUSTOM_TEST") owned_node_history = db_utils.create_test_history( node_id=owned_node.id) + owned_node_inventory = db_utils.create_test_inventory( + node_id=owned_node.id) # Leased nodes leased_node = db_utils.create_test_node( @@ -445,6 +450,8 @@ class TestRBACProjectScoped(TestACLBase): leased_node_history = db_utils.create_test_history( node_id=leased_node.id) + leased_node_inventory = db_utils.create_test_inventory( + node_id=leased_node.id) # Random objects that shouldn't be project visible other_node = db_utils.create_test_node( @@ -480,7 +487,9 @@ class TestRBACProjectScoped(TestACLBase): 'owner_allocation': fake_owner_allocation['uuid'], 'lessee_allocation': fake_leased_allocation['uuid'], 'owned_history_ident': owned_node_history['uuid'], - 'lessee_history_ident': leased_node_history['uuid']}) + 'lessee_history_ident': leased_node_history['uuid'], + 'owned_inventory': owned_node_inventory, + 'leased_inventory': leased_node_inventory}) @ddt.file_data('test_rbac_project_scoped.yaml') @ddt.unpack diff --git a/ironic/tests/unit/api/test_rbac_project_scoped.yaml b/ironic/tests/unit/api/test_rbac_project_scoped.yaml index b55439ad1a..b57f7fc5c3 100644 --- a/ironic/tests/unit/api/test_rbac_project_scoped.yaml +++ b/ironic/tests/unit/api/test_rbac_project_scoped.yaml @@ -3402,3 +3402,41 @@ node_history_get_entry_admin: method: get headers: *third_party_admin_headers assert_status: 404 + +# Node inventory support + +node_inventory_get_admin: + path: '/v1/nodes/{owner_node_ident}/inventory' + method: get + headers: *owner_admin_headers + assert_status: 200 + +node_inventory_get_member: + path: '/v1/nodes/{owner_node_ident}/inventory' + method: get + headers: *owner_member_headers + assert_status: 200 + +node_inventory_get_reader: + path: '/v1/nodes/{owner_node_ident}/inventory' + method: get + headers: *owner_reader_headers + assert_status: 200 + +lessee_node_inventory_get_admin: + path: '/v1/nodes/{node_ident}/inventory' + method: get + headers: *lessee_admin_headers + assert_status: 404 + +lessee_node_inventory_get_member: + path: '/v1/nodes/{node_ident}/inventory' + method: get + headers: *lessee_member_headers + assert_status: 404 + +lessee_node_inventory_get_reader: + path: '/v1/nodes/{node_ident}/inventory' + method: get + headers: *lessee_reader_headers + assert_status: 404 diff --git a/ironic/tests/unit/api/test_rbac_system_scoped.yaml b/ironic/tests/unit/api/test_rbac_system_scoped.yaml index d74a5fcaec..533356217c 100644 --- a/ironic/tests/unit/api/test_rbac_system_scoped.yaml +++ b/ironic/tests/unit/api/test_rbac_system_scoped.yaml @@ -2123,3 +2123,17 @@ node_history_get_entry_reader: method: get headers: *reader_headers assert_status: 200 + +# Node inventory support + +node_inventory_get_admin: + path: '/v1/nodes/{node_ident}/inventory' + method: get + headers: *admin_headers + assert_status: 200 + +node_inventory_get_reader: + path: '/v1/nodes/{node_ident}/inventory' + method: get + headers: *reader_headers + assert_status: 200 diff --git a/releasenotes/notes/add-node-inventory-7cde961b14caa11e.yaml b/releasenotes/notes/add-node-inventory-7cde961b14caa11e.yaml new file mode 100644 index 0000000000..93751e7d9c --- /dev/null +++ b/releasenotes/notes/add-node-inventory-7cde961b14caa11e.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Adds API version ``1.81`` which enables fetching node inventory + which might have been stored during introspection