API - Implement /events endpoint

Implements the POST /events endpoint. Allows external
entities to send events to Ironic. The default policy
for the new endpoint is "rule:is_admin".

Initial support for 'network' events properties implemented
but the EventsController will simply log a debug entry.

Story: 1304673
Task: 28988
Change-Id: I2cfebf2d0bedd35a33db7af60eaec0e5083fe16f
This commit is contained in:
Harald Jensås 2019-01-19 23:33:31 +01:00
parent e34941c327
commit 50205ca072
11 changed files with 416 additions and 2 deletions

View File

@ -2,6 +2,14 @@
REST API Version History
========================
1.54 (Stein, master)
--------------------
Added new endpoints for external ``events``:
* POST /v1/events for creating events. (This endpoint is only intended for
internal consumption.)
1.53 (Stein, master)
--------------------

View File

@ -29,6 +29,7 @@ from ironic.api.controllers.v1 import allocation
from ironic.api.controllers.v1 import chassis
from ironic.api.controllers.v1 import conductor
from ironic.api.controllers.v1 import driver
from ironic.api.controllers.v1 import event
from ironic.api.controllers.v1 import node
from ironic.api.controllers.v1 import port
from ironic.api.controllers.v1 import portgroup
@ -111,6 +112,9 @@ class V1(base.APIBase):
version = version.Version
"""Version discovery information."""
events = [link.Link]
"""Links to the events resource"""
@staticmethod
def convert():
v1 = V1()
@ -204,6 +208,14 @@ class V1(base.APIBase):
'allocations', '',
bookmark=True)
]
if utils.allow_expose_events():
v1.events = [link.Link.make_link('self', pecan.request.public_url,
'events', ''),
link.Link.make_link('bookmark',
pecan.request.public_url,
'events', '',
bookmark=True)
]
v1.version = version.default_version()
return v1
@ -221,6 +233,7 @@ class Controller(rest.RestController):
heartbeat = ramdisk.HeartbeatController()
conductors = conductor.ConductorsController()
allocations = allocation.AllocationsController()
events = event.EventsController()
@expose.expose(V1)
def get(self):

View File

@ -0,0 +1,54 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from ironic_lib import metrics_utils
from oslo_log import log
import pecan
from six.moves import http_client
from ironic.api.controllers.v1 import collection
from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import utils as api_utils
from ironic.api import expose
from ironic.common import exception
from ironic.common import policy
METRICS = metrics_utils.get_metrics_logger(__name__)
LOG = log.getLogger(__name__)
class EvtCollection(collection.Collection):
"""API representation of a collection of events."""
events = [types.eventtype]
"""A list containing event dict objects"""
class EventsController(pecan.rest.RestController):
"""REST controller for Events."""
@pecan.expose()
def _lookup(self):
if not api_utils.allow_expose_events():
pecan.abort(http_client.NOT_FOUND)
@METRICS.timer('EventsController.post')
@expose.expose(None, body=EvtCollection,
status_code=http_client.NO_CONTENT)
def post(self, evts):
if not api_utils.allow_expose_events():
raise exception.NotFound()
cdict = pecan.request.context.to_policy_values()
policy.authorize('baremetal:events:post', cdict, cdict)
for e in evts.events:
LOG.debug("Recieved external event: %s", e)

View File

@ -404,3 +404,83 @@ class VifType(JsonType):
viftype = VifType()
class EventType(wtypes.UserType):
"""A simple Event type."""
basetype = wtypes.DictType
name = 'event'
def _validate_network_port_event(value):
"""Validate network port event fields.
:param value: A event dict
:returns: value
:raises: Invalid if network port event not in proper format
"""
validators = {
'port_id': UuidType.validate,
'mac_address': MacAddressType.validate,
'status': wtypes.text,
'device_id': UuidType.validate,
'binding:host_id': UuidType.validate,
'binding:vnic_type': wtypes.text
}
keys = set(value)
net_keys = set(validators)
net_mandatory_fields = {'port_id', 'mac_address', 'status'}
# Check all keys are valid for network port event
invalid = keys.difference(EventType.mandatory_fields.union(net_keys))
if invalid:
raise exception.Invalid(_('%s are invalid keys') % (invalid))
# Check all mandatory fields for network port event is present
missing = net_mandatory_fields.difference(keys)
if missing:
raise exception.Invalid(_('Missing mandatory keys: %s')
% ', '.join(missing))
# Check all values are of expected type
for key in net_keys:
if value.get(key):
validators[key](value[key])
return value
mandatory_fields = {'event'}
event_validators = {
'network.bind_port': _validate_network_port_event,
'network.unbind_port': _validate_network_port_event,
'network.delete_port': _validate_network_port_event,
}
@staticmethod
def validate(value):
"""Validate the input
:param value: A event dict
:returns: value
:raises: Invalid if event not in proper format
"""
wtypes.DictType(wtypes.text, wtypes.text).validate(value)
keys = set(value)
# Check all mandatory fields are present
missing = EventType.mandatory_fields.difference(keys)
if missing:
raise exception.Invalid(_('Missing mandatory keys: %s') % missing)
# Check event is a supported event
if value['event'] not in EventType.event_validators:
raise exception.Invalid(_('%s is not a valid event.')
% value['event'])
return EventType.event_validators[value['event']](value)
eventtype = EventType()

View File

@ -421,6 +421,7 @@ VERSIONED_FIELDS = {
'owner': versions.MINOR_50_NODE_OWNER,
'description': versions.MINOR_51_NODE_DESCRIPTION,
'allocation_uuid': versions.MINOR_52_ALLOCATION,
'events': versions.MINOR_54_EVENTS,
}
for field in V31_FIELDS:
@ -1022,3 +1023,11 @@ def allow_port_is_smartnic():
return ((pecan.request.version.minor
>= versions.MINOR_53_PORT_SMARTNIC)
and objects.Port.supports_is_smartnic())
def allow_expose_events():
"""Check if accessing events endpoint is allowed.
Version 1.54 of the API added the events endpoint.
"""
return pecan.request.version.minor >= versions.MINOR_54_EVENTS

View File

@ -91,6 +91,7 @@ BASE_VERSION = 1
# v1.51: Add description to the node object.
# v1.52: Add allocation API.
# v1.53: Add support for Smart NIC port
# v1.54: Add events support.
MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1
@ -146,6 +147,7 @@ MINOR_50_NODE_OWNER = 50
MINOR_51_NODE_DESCRIPTION = 51
MINOR_52_ALLOCATION = 52
MINOR_53_PORT_SMARTNIC = 53
MINOR_54_EVENTS = 54
# When adding another version, update:
# - MINOR_MAX_VERSION
@ -153,7 +155,7 @@ MINOR_53_PORT_SMARTNIC = 53
# explanation of what changed in the new version
# - common/release_mappings.py, RELEASE_MAPPING['master']['api']
MINOR_MAX_VERSION = MINOR_53_PORT_SMARTNIC
MINOR_MAX_VERSION = MINOR_54_EVENTS
# String representations of the minor and maximum versions
_MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -425,6 +425,14 @@ allocation_policies = [
{'path': '/nodes/{node_ident}/allocation', 'method': 'DELETE'}]),
]
event_policies = [
policy.DocumentedRuleDefault(
'baremetal:events:post',
'rule:is_admin',
'Post events',
[{'path': '/events', 'method': 'POST'}])
]
def list_policies():
policies = itertools.chain(
@ -439,6 +447,7 @@ def list_policies():
volume_policies,
conductor_policies,
allocation_policies,
event_policies
)
return policies

View File

@ -131,7 +131,7 @@ RELEASE_MAPPING = {
}
},
'master': {
'api': '1.53',
'api': '1.54',
'rpc': '1.48',
'objects': {
'Allocation': ['1.0'],

View File

@ -0,0 +1,176 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""
Tests for the API /events methods.
"""
import mock
from six.moves import http_client
from ironic.api.controllers import base as api_base
from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import versions
from ironic.tests.unit.api import base as test_api_base
from ironic.tests.unit.api.utils import fake_event_validator
def get_fake_port_event():
return {'event': 'network.bind_port',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': 'ACTIVE',
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': 'baremetal'}
class TestPost(test_api_base.BaseApiTest):
def setUp(self):
super(TestPost, self).setUp()
self.headers = {api_base.Version.string: str(
versions.max_version_string())}
@mock.patch.object(types.EventType, 'event_validators',
{'valid.event': fake_event_validator})
def test_events(self):
events_dict = {'events': [{'event': 'valid.event'}]}
response = self.post_json('/events', events_dict, headers=self.headers)
self.assertEqual(http_client.NO_CONTENT, response.status_int)
@mock.patch.object(types.EventType, 'event_validators',
{'valid.event1': fake_event_validator,
'valid.event2': fake_event_validator,
'valid.event3': fake_event_validator})
def test_multiple_events(self):
events_dict = {'events': [{'event': 'valid.event1'},
{'event': 'valid.event2'},
{'event': 'valid.event3'}]}
response = self.post_json('/events', events_dict, headers=self.headers)
self.assertEqual(http_client.NO_CONTENT, response.status_int)
def test_events_does_not_contain_event(self):
events_dict = {'events': [{'INVALID': 'fake.event'}]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
@mock.patch.object(types.EventType, 'event_validators',
{'valid.event': fake_event_validator})
def test_events_invalid_event(self):
events_dict = {'events': [{'event': 'invalid.event'}]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_unknown_event_property(self):
events_dict = {'events': [{'event': 'network.unbind_port',
'UNKNOWN': 'EVENT_PROPERTY'}]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_bind_port_events(self):
events_dict = {'events': [get_fake_port_event()]}
response = self.post_json('/events', events_dict, headers=self.headers)
self.assertEqual(http_client.NO_CONTENT, response.status_int)
def test_network_unbind_port_events(self):
events_dict = {'events': [get_fake_port_event()]}
events_dict['events'][0].update({'event': 'network.unbind_port'})
response = self.post_json('/events', events_dict, headers=self.headers)
self.assertEqual(http_client.NO_CONTENT, response.status_int)
def test_network_delete_port_events(self):
events_dict = {'events': [get_fake_port_event()]}
events_dict['events'][0].update({'event': 'network.delete_port'})
response = self.post_json('/events', events_dict, headers=self.headers)
self.assertEqual(http_client.NO_CONTENT, response.status_int)
def test_network_port_event_invalid_mac_address(self):
port_evt = get_fake_port_event()
port_evt.update({'mac_address': 'INVALID_MAC_ADDRESS'})
events_dict = {'events': [port_evt]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_port_event_invalid_device_id(self):
port_evt = get_fake_port_event()
port_evt.update({'device_id': 'DEVICE_ID_SHOULD_BE_UUID'})
events_dict = {'events': [port_evt]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_port_event_invalid_port_id(self):
port_evt = get_fake_port_event()
port_evt.update({'port_id': 'PORT_ID_SHOULD_BE_UUID'})
events_dict = {'events': [port_evt]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_port_event_invalid_status(self):
port_evt = get_fake_port_event()
port_evt.update({'status': ['status', 'SHOULD', 'BE', 'TEXT']})
events_dict = {'events': [port_evt]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_port_event_invalid_binding_vnic_type(self):
port_evt = get_fake_port_event()
port_evt.update({'binding:vnic_type': ['binding:vnic_type', 'SHOULD',
'BE', 'TEXT']})
events_dict = {'events': [port_evt]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
def test_network_port_event_invalid_binding_host_id(self):
port_evt = get_fake_port_event()
port_evt.update({'binding:host_id': ['binding:host_id', 'IS',
'NODE_UUID', 'IN', 'IRONIC']})
events_dict = {'events': [port_evt]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=self.headers)
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])
@mock.patch.object(types.EventType, 'event_validators',
{'valid.event': fake_event_validator})
def test_events_unsupported_api_version(self):
headers = {api_base.Version.string: '1.50'}
events_dict = {'events': [{'event': 'valid.event'}]}
response = self.post_json('/events', events_dict, expect_errors=True,
headers=headers)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertTrue(response.json['error_message'])

View File

@ -27,6 +27,7 @@ from ironic.api.controllers.v1 import types
from ironic.common import exception
from ironic.common import utils
from ironic.tests import base
from ironic.tests.unit.api.utils import fake_event_validator
class TestMacAddressType(base.TestCase):
@ -393,3 +394,60 @@ class TestVifType(base.TestCase):
v = types.viftype
self.assertRaises(exception.InvalidUuidOrName,
v.frombasetype, {'id': 5678})
class TestEventType(base.TestCase):
def setUp(self):
super(TestEventType, self).setUp()
self.v = types.eventtype
@mock.patch.object(types.EventType, 'event_validators',
{'valid.event': fake_event_validator})
def test_simple_event_type(self):
value = {'event': 'valid.event'}
self.assertItemsEqual(value, self.v.validate(value))
@mock.patch.object(types.EventType, 'event_validators',
{'valid.event': fake_event_validator})
def test_invalid_event_type(self):
value = {'event': 'invalid.event'}
self.assertRaisesRegex(exception.Invalid, 'invalid.event is not a '
'valid event.',
self.v.validate, value)
def test_event_missing_madatory_field(self):
value = {'invalid': 'invalid'}
self.assertRaisesRegex(exception.Invalid, 'Missing mandatory keys:',
self.v.validate, value)
def test_network_port_event(self):
value = {'event': 'network.bind_port',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'de:ad:ca:fe:ba:be',
'status': 'ACTIVE',
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': 'baremetal'
}
self.assertItemsEqual(value, self.v.validate(value))
def test_invalid_mac_network_port_event(self):
value = {'event': 'network.bind_port',
'port_id': '11111111-aaaa-bbbb-cccc-555555555555',
'mac_address': 'INVALID_MAC_ADDRESS',
'status': 'ACTIVE',
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': 'baremetal'
}
self.assertRaises(exception.InvalidMAC, self.v.validate, value)
def test_missing_mandatory_fields_network_port_event(self):
value = {'event': 'network.bind_port',
'device_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:host_id': '22222222-aaaa-bbbb-cccc-555555555555',
'binding:vnic_type': 'baremetal'
}
self.assertRaisesRegex(exception.Invalid, 'Missing mandatory keys:',
self.v.validate, value)

View File

@ -195,3 +195,8 @@ def allocation_post_data(**kw):
allocation = db_utils.get_test_allocation(**kw)
return {key: value for key, value in allocation.items()
if key in _ALLOCATION_POST_FIELDS}
def fake_event_validator(v):
"""A fake event validator"""
return v