summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHarald Jensås <hjensas@redhat.com>2019-01-19 23:33:31 +0100
committerHarald Jensås <hjensas@redhat.com>2019-02-15 06:17:02 +0100
commit50205ca0724c9f03731e3441bc525b5c3650369f (patch)
treebec2803b8eb6c7aa080f01febb0612c6c3fd7685
parente34941c3279907a013b2b6ae023a089e58c3ed11 (diff)
downloadironic-50205ca0724c9f03731e3441bc525b5c3650369f.tar.gz
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
-rw-r--r--doc/source/contributor/webapi-version-history.rst8
-rw-r--r--ironic/api/controllers/v1/__init__.py13
-rw-r--r--ironic/api/controllers/v1/event.py54
-rw-r--r--ironic/api/controllers/v1/types.py80
-rw-r--r--ironic/api/controllers/v1/utils.py9
-rw-r--r--ironic/api/controllers/v1/versions.py4
-rw-r--r--ironic/common/policy.py9
-rw-r--r--ironic/common/release_mappings.py2
-rw-r--r--ironic/tests/unit/api/controllers/v1/test_event.py176
-rw-r--r--ironic/tests/unit/api/controllers/v1/test_types.py58
-rw-r--r--ironic/tests/unit/api/utils.py5
11 files changed, 416 insertions, 2 deletions
diff --git a/doc/source/contributor/webapi-version-history.rst b/doc/source/contributor/webapi-version-history.rst
index e16cf2999..f8ce87873 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 52a7cfe6c..6dc71c3e0 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 000000000..9cb44293c
--- /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 e2b04bd72..a8737d862 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 f2c7a5ec9..9846179a2 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 7b321774c..8fe33c7c6 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 0c55f4cf1..3845580b8 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 3688be00c..e09f3ca7c 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 000000000..7ca81dce8
--- /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 7c85c363e..9d5600f90 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 ea6335099..36321154b 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