From 50205ca0724c9f03731e3441bc525b5c3650369f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Harald=20Jens=C3=A5s?= Date: Sat, 19 Jan 2019 23:33:31 +0100 Subject: [PATCH] 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 --- .../contributor/webapi-version-history.rst | 8 + ironic/api/controllers/v1/__init__.py | 13 ++ ironic/api/controllers/v1/event.py | 54 ++++++ ironic/api/controllers/v1/types.py | 80 ++++++++ ironic/api/controllers/v1/utils.py | 9 + ironic/api/controllers/v1/versions.py | 4 +- ironic/common/policy.py | 9 + ironic/common/release_mappings.py | 2 +- .../unit/api/controllers/v1/test_event.py | 176 ++++++++++++++++++ .../unit/api/controllers/v1/test_types.py | 58 ++++++ ironic/tests/unit/api/utils.py | 5 + 11 files changed, 416 insertions(+), 2 deletions(-) create mode 100644 ironic/api/controllers/v1/event.py create mode 100644 ironic/tests/unit/api/controllers/v1/test_event.py diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst index e16cf29993..f8ce878734 100644 --- a/doc/source/contributor/webapi-version-history.rst +++ b/doc/source/contributor/webapi-version-history.rst @@ -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) -------------------- diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py index 52a7cfe6c6..6dc71c3e01 100644 --- a/ironic/api/controllers/v1/__init__.py +++ b/ironic/api/controllers/v1/__init__.py @@ -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): diff --git a/ironic/api/controllers/v1/event.py b/ironic/api/controllers/v1/event.py new file mode 100644 index 0000000000..9cb44293c9 --- /dev/null +++ b/ironic/api/controllers/v1/event.py @@ -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) diff --git a/ironic/api/controllers/v1/types.py b/ironic/api/controllers/v1/types.py index e2b04bd728..a8737d8629 100644 --- a/ironic/api/controllers/v1/types.py +++ b/ironic/api/controllers/v1/types.py @@ -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() diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py index f2c7a5ec94..9846179a2b 100644 --- a/ironic/api/controllers/v1/utils.py +++ b/ironic/api/controllers/v1/utils.py @@ -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 diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py index 7b321774c5..8fe33c7c6a 100644 --- a/ironic/api/controllers/v1/versions.py +++ b/ironic/api/controllers/v1/versions.py @@ -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) diff --git a/ironic/common/policy.py b/ironic/common/policy.py index 0c55f4cf13..3845580b83 100644 --- a/ironic/common/policy.py +++ b/ironic/common/policy.py @@ -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 diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py index 3688be00ca..e09f3ca7cc 100644 --- a/ironic/common/release_mappings.py +++ b/ironic/common/release_mappings.py @@ -131,7 +131,7 @@ RELEASE_MAPPING = { } }, 'master': { - 'api': '1.53', + 'api': '1.54', 'rpc': '1.48', 'objects': { 'Allocation': ['1.0'], diff --git a/ironic/tests/unit/api/controllers/v1/test_event.py b/ironic/tests/unit/api/controllers/v1/test_event.py new file mode 100644 index 0000000000..7ca81dce8b --- /dev/null +++ b/ironic/tests/unit/api/controllers/v1/test_event.py @@ -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']) diff --git a/ironic/tests/unit/api/controllers/v1/test_types.py b/ironic/tests/unit/api/controllers/v1/test_types.py index 7c85c363e8..9d5600f901 100644 --- a/ironic/tests/unit/api/controllers/v1/test_types.py +++ b/ironic/tests/unit/api/controllers/v1/test_types.py @@ -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) diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py index ea6335099b..36321154bb 100644 --- a/ironic/tests/unit/api/utils.py +++ b/ironic/tests/unit/api/utils.py @@ -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