API endpoints to get node history

Adds API for retrieving node history events
via a node. Includes pagination and limitation
of the response set.

Story: 2002980
Tas: 42961

Change-Id: I22a92fa6c30d721f6a5dd0670b2e0a9cf76ad7b1
This commit is contained in:
Julia Kreger 2021-08-07 21:31:51 -07:00
parent 20503d94e5
commit fb9eae7412
17 changed files with 607 additions and 8 deletions

View File

@ -0,0 +1,76 @@
.. -*- rst -*-
================
History of nodes
================
.. versionadded:: 1.78
Identifying history of events from nodes is available via API version 1.78 via
the ``v1/nodes/{node_ident}/history`` endpoint. In default policy
configuration, only "System" scoped users as well as owners who are listed
owners of associated nodes can list and retrieve nodes.
List history entries for a node
===============================
.. rest_method:: GET /v1/nodes/{node_ident}/history
Normal response code: 200
Error codes: 400,401,403,404
Request
-------
.. rest_parameters:: parameters.yaml
- node_ident: node_ident
- detail: detail
- marker: marker
- limit: limit
Response
--------
.. rest_parameters:: parameters.yaml
- history: n_history
**Example list of history events from a node:**
.. literalinclude:: samples/node-history-list-response.json
:language: javascript
Retrieve a specific history entry
=================================
.. rest_method:: GET /v1/nodes/{node_ident}/history/{history_event_uuid}
Request
-------
.. rest_parameters:: parameters.yaml
- node_ident: node_ident
- history_event_uuid: history_event_ident
Response
--------
.. rest_parameters:: parameters.yaml
- uuid: uuid
- created_at: created_at
- user: history_user_ident
- severity: history_severity
- event: history_event
- event_type: history_event_type
- conductor: hostname
Deleting history entries for a node
===================================
Due to the nature of an immutable history record, records cannot be deleted
via the REST API. Records and ultimately expired history records are managed
by the conductor.

View File

@ -27,6 +27,7 @@
.. include:: baremetal-api-v1-allocation.inc
.. include:: baremetal-api-v1-node-allocation.inc
.. include:: baremetal-api-v1-deploy-templates.inc
.. include:: baremetal-api-v1-nodes-history.inc
.. NOTE(dtantsur): keep chassis close to the end since it's semi-deprecated
.. include:: baremetal-api-v1-chassis.inc
.. NOTE(dtantsur): keep misc last, since it covers internal API

View File

@ -74,6 +74,12 @@ driver_ident:
in: path
required: true
type: string
history_event_ident:
description: |
The UUID of a history event.
in: path
required: true
type: string
hostname_ident:
description: |
The hostname of the conductor.
@ -971,6 +977,36 @@ fault:
in: body
required: false
type: string
history_event:
description: |
The event message body which has been logged related to the node for
this error.
in: body
required: true
type: string
history_event_type:
description: |
Short descriptive string to indicate where the error occurred at to
enable API users/System Operators to be able to identify repeated
issues in a particular area of operation, such as 'deployment',
'console', 'cleaning', 'monitoring'.
in: body
required: true
type: string
history_severity:
description: |
Severity indicator for the event being returned. Typically this will
indicate if this was an Error or Informational entry.
in: body
required: true
type: string
history_user_ident:
description: |
The UUID value representing the user whom appears to have caused
the recorded event.
in: body
required: true
type: string
hostname:
description: |
The hostname of this conductor.
@ -1122,6 +1158,12 @@ n_description:
in: body
required: true
type: string
n_history:
description: |
History events attached to this node.
in: body
required: true
type: array
n_ind_state:
description: |
The state of an indicator of the component of the node. Possible values

View File

@ -0,0 +1,16 @@
{
"history": [
{
"uuid": "e5840e39-b4ba-4a93-8071-cff9aa2c9633",
"created_at": "2021-09-15T17:45:04.686541+00:00",
"severity": "ERROR",
"event": "Something is wrong",
"links": [
{
"href": "http://localhost/v1/nodes/1be26c0b-03f2-4d2e-ae87-c02d7f33c123/history/e5840e39-b4ba-4a93-8071-cff9aa2c9633",
"rel": "self"
}
]
}
]
}

View File

@ -2,8 +2,17 @@
REST API Version History
========================
1.77
1.78 (Xena, ?)
----------------------
Add endpoints to allow history events for nodes to be retrieved via
the REST API.
* ``GET /v1/nodes/{node_ident}/history/``
* ``GET /v1/nodes/{node_ident}/history/{event_uuid}``
1.77 (Xena, ?)
----------------------
Add a fields selector to the the Drivers list:
* ``GET /v1/drivers?fields=``
Also add a fields selector to the the Driver detail:

View File

@ -1840,6 +1840,107 @@ class NodeVIFController(rest.RestController):
vif_id=vif_id, topic=topic)
class NodeHistoryController(rest.RestController):
detail_fields = ['uuid', 'created_at', 'severity', 'event_type',
'event', 'conductor', 'user']
standard_fields = ['uuid', 'created_at', 'severity', 'event']
def __init__(self, node_ident):
super(NodeHistoryController).__init__()
self.node_ident = node_ident
def _history_event_convert_with_links(self, node_uuid, event,
detail=False):
"""Add link and convert history event"""
url = api.request.public_url
if not detail:
fields = self.standard_fields
else:
fields = self.detail_fields
event_entry = api_utils.object_to_dict(
event,
link_resource='nodes',
fields=fields)
if not detail:
# The spec for this feature calls to truncate the event
# field if not detailed, which makes sense in some environments
# with many events, espescialy if the event text is particullarlly
# long.
entry_len = len(event_entry['event'])
if entry_len > 255:
event_entry['event'] = event_entry['event'][0:251] + '...'
else:
event_entry['event'] = event_entry['event'][0:entry_len]
# These records cannot be changed by the API consumer,
# and updated_at gets handed up from the db model
# regardless if we want it or not. As such, strip from
# the reply.
event_entry.pop('updated_at')
event_entry['links'] = [
link.make_link(
'self', url,
'nodes',
'%s/history/%s' % (node_uuid, event.uuid)
)
]
return event_entry
@METRICS.timer('NodeHistoryController.get_all')
@method.expose()
@args.validate(details=args.boolean, marker=args.uuid, limit=args.integer)
def get_all(self, **kwargs):
"""List node history."""
node = api_utils.check_node_policy_and_retrieve(
'baremetal:node:history:get', self.node_ident)
if kwargs.get('detail'):
detail = True
fields = self.detail_fields
else:
detail = False
fields = self.standard_fields
marker_obj = None
marker = kwargs.get('marker')
if marker:
marker_obj = objects.NodeHistory.get_by_uuid(api.request.context,
marker)
limit = kwargs.get('limit')
events = objects.NodeHistory.list_by_node_id(api.request.context,
node.id,
marker=marker_obj,
limit=limit)
return collection.list_convert_with_links(
items=[
self._history_event_convert_with_links(
node.uuid, event, detail=detail) for event in events
],
item_name='history',
fields=fields,
marker=marker_obj,
limit=limit,
)
@METRICS.timer('NodeHistoryController.get_one')
@method.expose()
@args.validate(event=args.uuid_or_name)
def get_one(self, event):
"""Get a node history entry"""
node = api_utils.check_node_policy_and_retrieve(
'baremetal:node:history:get', self.node_ident)
# TODO(TheJulia): Need to check policy to make sure if policy
# check fails, that the entry cannot be found.
event = objects.NodeHistory.get_by_uuid(api.request.context,
event)
return self._history_event_convert_with_links(
node.uuid, event, detail=True)
class NodesController(rest.RestController):
"""REST controller for Nodes."""
@ -1885,6 +1986,7 @@ class NodesController(rest.RestController):
'traits': NodeTraitsController,
'bios': bios.NodeBiosController,
'allocation': allocation.NodeAllocationController,
'history': NodeHistoryController,
}
@pecan.expose()
@ -1906,7 +2008,9 @@ class NodesController(rest.RestController):
or (remainder[0] == 'bios'
and not api_utils.allow_bios_interface())
or (remainder[0] == 'allocation'
and not api_utils.allow_allocations())):
and not api_utils.allow_allocations())
or (remainder[0] == 'history'
and not api_utils.allow_node_history())):
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

View File

@ -1334,6 +1334,11 @@ def allow_reset_interfaces():
return api.request.version.minor >= versions.MINOR_45_RESET_INTERFACES
def allow_node_history():
"""Check if node history access is permitted by API version."""
return api.request.version.minor >= versions.MINOR_78_NODE_HISTORY
def get_request_return_fields(fields, detail, default_fields,
check_detail_version=allow_detail_query,
check_fields_version=None):

View File

@ -115,6 +115,7 @@ BASE_VERSION = 1
# v1.75: Add boot_mode, secure_boot fields to node object.
# v1.76: Add support for changing boot_mode and secure_boot state
# v1.77: Add fields selector to drivers list and driver detail.
# v1.78: Add node history endpoint
MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1
@ -194,6 +195,7 @@ MINOR_74_BIOS_REGISTRY = 74
MINOR_75_NODE_BOOT_MODE = 75
MINOR_76_NODE_CHANGE_BOOT_MODE = 76
MINOR_77_DRIVER_FIELDS_SELECTOR = 77
MINOR_78_NODE_HISTORY = 78
# When adding another version, update:
# - MINOR_MAX_VERSION
@ -201,7 +203,7 @@ MINOR_77_DRIVER_FIELDS_SELECTOR = 77
# explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_77_DRIVER_FIELDS_SELECTOR
MINOR_MAX_VERSION = MINOR_78_NODE_HISTORY
# String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -420,7 +420,6 @@ deprecated_bios_disable_cleaning = policy.DeprecatedRule(
deprecated_since=versionutils.deprecated.WALLABY
)
node_policies = [
policy.DocumentedRuleDefault(
name='baremetal:node:create',
@ -911,6 +910,24 @@ node_policies = [
],
deprecated_rule=deprecated_bios_disable_cleaning
),
policy.DocumentedRuleDefault(
name='baremetal:node:history:get',
check_str=SYSTEM_OR_OWNER_READER,
scope_types=['system', 'project'],
description='Filter to allow operators to retreive history records '
'for a node.',
operations=[
{'path': '/nodes/{node_ident}/history', 'method': 'GET'},
{'path': '/nodes/{node_ident}/history/{event_ident}',
'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
),
]
deprecated_port_reason = """

View File

@ -371,7 +371,7 @@ RELEASE_MAPPING = {
}
},
'master': {
'api': '1.77',
'api': '1.78',
'rpc': '1.55',
'objects': {
'Allocation': ['1.1'],

View File

@ -2319,7 +2319,7 @@ class Connection(api.Connection):
raise exception.NodeHistoryNotFound(history=history_uuid)
def get_node_history_list(self, limit=None, marker=None,
sort_key=None, sort_dir=None):
sort_key='created_at', sort_dir='asc'):
return _paginate_query(models.NodeHistory, limit, marker, sort_key,
sort_dir)

View File

@ -7720,3 +7720,132 @@ class TestTraits(test_api_base.BaseApiTest):
headers={api_base.Version.string: "1.36"},
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, ret.status_code)
class TestNodeHistory(test_api_base.BaseApiTest):
def setUp(self):
super(TestNodeHistory, self).setUp()
self.version = "1.78"
self.node = obj_utils.create_test_node(
self.context,
provision_state=states.AVAILABLE, name='node-54')
self.node.save()
self.node.obj_reset_changes()
def _add_history_entries(self):
self.event1 = objects.NodeHistory(node_id=self.node.id, event='meow',
conductor='cat-tree1',
user='peaches')
self.event1.create()
self.event2 = objects.NodeHistory(node_id=self.node.id, event='purr',
conductor='cat-tree2',
user='sage')
self.event2.create()
self.event3 = objects.NodeHistory(node_id=self.node.id,
event='g' + 'rrrr' * 64 + '!',
conductor='cat-tree3',
user='bella')
self.event3.create()
def test_get_all_history(self):
ret = self.get_json('/nodes/%s/history' % self.node.uuid,
headers={api_base.Version.string: self.version})
self.assertEqual({'history': []}, ret)
def test_get_all_old_version(self):
ret = self.get_json('/nodes/%s/history' % self.node.uuid,
headers={api_base.Version.string: "1.77"},
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, ret.status_code)
def test_get_all_history_returns_entries(self):
self._add_history_entries()
ret = self.get_json('/nodes/%s/history' % self.node.uuid,
headers={api_base.Version.string: self.version})
self.assertIn('history', ret)
entries = ret['history']
self.assertEqual(3, len(entries))
self.assertEqual('meow', entries[0]['event'])
self.assertEqual('purr', entries[1]['event'])
self.assertIn('grr', entries[2]['event'])
self.assertNotIn('r!', entries[2]['event'])
self.assertIn('...', entries[2]['event'])
for entry in [0, 1, 2]:
for field in ['conductor', 'user']:
self.assertNotIn(field, entries[entry])
self.assertIn('severity', entries[entry])
def test_get_all_history_returns_detail(self):
self._add_history_entries()
ret = self.get_json('/nodes/%s/history?detail=true' % self.node.uuid,
headers={api_base.Version.string: self.version})
self.assertIn('history', ret)
entries = ret['history']
self.assertEqual(3, len(entries))
self.assertEqual('meow', entries[0]['event'])
self.assertEqual('purr', entries[1]['event'])
self.assertIn('grr', entries[2]['event'])
self.assertIn('r!', entries[2]['event'])
for entry in [0, 1, 2]:
for field in ['conductor', 'user', 'severity', 'event_type']:
self.assertIn(field, entries[entry])
def test_get_history_item(self):
self._add_history_entries()
record = self.get_json('/nodes/%s/history/%s' % (self.node.uuid,
self.event1.uuid),
headers={api_base.Version.string: self.version})
self.assertEqual(8, len(record))
expected_keys = ['created_at', 'links', 'event',
'event_type', 'severity', 'user', 'uuid']
for key in expected_keys:
self.assertIn(key, record)
self.assertNotIn('updated_at', record)
self.assertEqual('cat-tree1', record['conductor'])
self.assertEqual('meow', record['event'])
self.assertEqual('peaches', record['user'])
self.assertEqual(self.event1.uuid, record['uuid'])
def test_get_history_item_not_found(self):
self._add_history_entries()
ret = self.get_json('/nodes/%s/history/52949728-59fc-'
'4651-84c8-b0a16b469372' % 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_history_item_old_version(self):
ret = self.get_json('/nodes/%s/history/1234' % self.node.uuid,
headers={api_base.Version.string: "1.77"},
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, ret.status_code)
def test_get_all_pagination(self):
self._add_history_entries()
# First request, initial request with a limit of 1.
ret = self.get_json('/nodes/%s/history?limit=1' % self.node.uuid,
headers={api_base.Version.string: self.version})
self.assertIn('history', ret)
entries = ret['history']
self.assertEqual(1, len(entries))
result_uuid = entries[0]['uuid']
self.assertEqual(self.event1.uuid, result_uuid)
# Second request
ret = self.get_json('/nodes/%s/history?limit=1&marker=%s' %
(self.node.uuid, result_uuid),
headers={api_base.Version.string: self.version})
self.assertIn('history', ret)
entries = ret['history']
self.assertEqual(1, len(entries))
result_uuid = entries[0]['uuid']
self.assertEqual(self.event2.uuid, result_uuid)
# Third request
ret = self.get_json('/nodes/%s/history?limit=1&marker=%s' %
(self.node.uuid, result_uuid),
headers={api_base.Version.string: self.version})
self.assertIn('history', ret)
entries = ret['history']
self.assertEqual(1, len(entries))
result_uuid = entries[0]['uuid']
self.assertEqual(self.event3.uuid, result_uuid)

View File

@ -275,7 +275,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase):
value=fake_setting)
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)
# dedicated node for portgroup addition test to avoid
# false positives with test runners.
db_utils.create_test_node(
@ -298,6 +298,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase):
'trait': fake_trait,
'volume_target_ident': fake_db_volume_target['uuid'],
'volume_connector_ident': fake_db_volume_connector['uuid'],
'history_ident': fake_history['uuid'],
})
@ -402,6 +403,8 @@ class TestRBACProjectScoped(TestACLBase):
node_id=owned_node['id'],
owner=owner_project_id,
resource_class="CUSTOM_TEST")
owned_node_history = db_utils.create_test_history(
node_id=owned_node.id)
# Leased nodes
fake_allocation_id = 61
@ -428,6 +431,9 @@ class TestRBACProjectScoped(TestACLBase):
owner=lessee_project_id,
resource_class="CUSTOM_LEASED")
leased_node_history = db_utils.create_test_history(
node_id=leased_node.id)
# Random objects that shouldn't be project visible
other_port = db_utils.create_test_port(
uuid='abfd8dbb-1732-449a-b760-2224035c6b99',
@ -460,7 +466,9 @@ class TestRBACProjectScoped(TestACLBase):
'other_portgroup_ident': other_pgroup['uuid'],
'driver_name': 'fake-driverz',
'owner_allocation': fake_owner_allocation['uuid'],
'lessee_allocation': fake_leased_allocation['uuid']})
'lessee_allocation': fake_leased_allocation['uuid'],
'owned_history_ident': owned_node_history['uuid'],
'lessee_history_ident': leased_node_history['uuid']})
@ddt.file_data('test_rbac_project_scoped.yaml')
@ddt.unpack

View File

@ -2349,3 +2349,49 @@ chassis_chassis_id_delete_observer:
headers: *observer_headers
assert_status: 403
deprecated: true
node_history_get_admin:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *admin_headers
assert_status: 200
deprecated: true
assert_list_length:
history: 1
node_history_get_member:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *member_headers
assert_status: 404
deprecated: true
node_history_get_observer:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *observer_headers
assert_status: 200
deprecated: true
assert_list_length:
history: 1
node_history_get_entry_admin:
path: '/v1/nodes/{node_ident}/history/{history_ident}'
method: get
headers: *admin_headers
assert_status: 200
deprecated: true
node_history_get_entry_member:
path: '/v1/nodes/{node_ident}/history/{history_ident}'
method: get
headers: *member_headers
assert_status: 404
deprecated: true
node_history_get_entry_observer:
path: '/v1/nodes/{node_ident}/history/{history_ident}'
method: get
headers: *observer_headers
assert_status: 200
deprecated: true

View File

@ -2629,3 +2629,95 @@ third_party_admin_cannot_create_chassis:
body:
description: 'test-chassis'
assert_status: 500
# Node history entries
node_history_get_admin:
path: '/v1/nodes/{owner_node_ident}/history'
method: get
headers: *owner_admin_headers
assert_status: 200
assert_list_length:
history: 1
node_history_get_member:
path: '/v1/nodes/{owner_node_ident}/history'
method: get
headers: *owner_member_headers
assert_status: 200
assert_list_length:
history: 1
node_history_get_reader:
path: '/v1/nodes/{owner_node_ident}/history'
method: get
headers: *owner_reader_headers
assert_status: 200
assert_list_length:
history: 1
node_history_get_entry_admin:
path: '/v1/nodes/{owner_node_ident}/history/{owned_history_ident}'
method: get
headers: *owner_admin_headers
assert_status: 200
node_history_get_entry_member:
path: '/v1/nodes/{owner_node_ident}/history/{owned_history_ident}'
method: get
headers: *owner_member_headers
assert_status: 200
node_history_get_entry_reader:
path: '/v1/nodes/{owner_node_ident}/history/{owned_history_ident}'
method: get
headers: *owner_reader_headers
assert_status: 200
lessee_node_history_get_admin:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *lessee_admin_headers
assert_status: 404
lessee_node_history_get_member:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *lessee_member_headers
assert_status: 404
lessee_node_history_get_reader:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *lessee_reader_headers
assert_status: 404
lessee_node_history_get_entry_admin:
path: '/v1/nodes/{node_ident}/history/{lessee_history_ident}'
method: get
headers: *lessee_admin_headers
assert_status: 404
lessee_history_get_entry_member:
path: '/v1/nodes/{node_ident}/history/{lessee_history_ident}'
method: get
headers: *lessee_member_headers
assert_status: 404
lessee_node_history_get_entry_reader:
path: '/v1/nodes/{node_ident}/history/{lessee_history_ident}'
method: get
headers: *lessee_reader_headers
assert_status: 404
third_party_admin_cannot_get_node_history:
path: '/v1/nodes/{owner_node_ident}'
method: get
headers: *third_party_admin_headers
assert_status: 404
node_history_get_entry_admin:
path: '/v1/nodes/{owner_node_ident}/history/{owned_history_ident}'
method: get
headers: *third_party_admin_headers
assert_status: 404

View File

@ -2079,3 +2079,47 @@ chassis_chassis_id_delete_reader:
method: delete
headers: *reader_headers
assert_status: 403
# Node history entries
node_history_get_admin:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *admin_headers
assert_status: 200
assert_list_length:
history: 1
node_history_get_member:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *scoped_member_headers
assert_status: 200
assert_list_length:
history: 1
node_history_get_reader:
path: '/v1/nodes/{node_ident}/history'
method: get
headers: *reader_headers
assert_status: 200
assert_list_length:
history: 1
node_history_get_entry_admin:
path: '/v1/nodes/{node_ident}/history/{history_ident}'
method: get
headers: *admin_headers
assert_status: 200
node_history_get_entry_member:
path: '/v1/nodes/{node_ident}/history/{history_ident}'
method: get
headers: *scoped_member_headers
assert_status: 200
node_history_get_entry_reader:
path: '/v1/nodes/{node_ident}/history/{history_ident}'
method: get
headers: *reader_headers
assert_status: 200

View File

@ -0,0 +1,8 @@
---
features:
- |
Adds API version ``1.78`` which provides the capability to retrieve
node history events which may have been recorded in the process of
management of the node, which may be aid in troubleshooting or identifying
a problem area with a specific node or configuration which has been
supplied.