From 4a42070fee6fcd2b6dbb9cc9f162ed52407283a7 Mon Sep 17 00:00:00 2001 From: Angus Salkeld Date: Wed, 30 Oct 2013 09:22:52 +1100 Subject: [PATCH] Send usage notifications on major stack events These notifications can then be used for usage/billing/alarming purposes. Some of the base fields are based on the existing nova fields: https://wiki.openstack.org/wiki/SystemUsageData This patch just has the minimum fields needed we can add more as users ask. We can also add more notifications as we need: - automated actions (autoscaling, restart) - time based .exists notifications implements blueprint send-notification Change-Id: I63ad5402037799842643eb642af15b54ef8d7483 --- heat/engine/api.py | 21 +++ heat/engine/notification.py | 53 +++++++ heat/engine/parser.py | 2 + heat/rpc/api.py | 18 +++ heat/tests/test_autoscaling_update_policy.py | 7 + heat/tests/test_notifications.py | 145 +++++++++++++++++++ 6 files changed, 246 insertions(+) create mode 100644 heat/engine/notification.py create mode 100644 heat/tests/test_notifications.py diff --git a/heat/engine/api.py b/heat/engine/api.py index 57cf90b33..340490a0e 100644 --- a/heat/engine/api.py +++ b/heat/engine/api.py @@ -141,6 +141,27 @@ def format_event(event): return result +def format_notification_body(stack): + # some other posibilities here are: + # - template name + # - template size + # - resource count + if stack.status is not None and stack.action is not None: + state = '_'.join(stack.state) + else: + state = 'Unknown' + result = { + api.NOTIFY_TENANT_ID: stack.context.tenant_id, + api.NOTIFY_USER_ID: stack.context.user, + api.NOTIFY_STACK_ID: stack.identifier().arn(), + api.NOTIFY_STACK_NAME: stack.name, + api.NOTIFY_STATE: state, + api.NOTIFY_STATE_REASON: stack.status_reason, + api.NOTIFY_CREATE_AT: timeutils.isotime(stack.created_time), + } + return result + + def format_watch(watch): result = { diff --git a/heat/engine/notification.py b/heat/engine/notification.py new file mode 100644 index 000000000..bbce00c47 --- /dev/null +++ b/heat/engine/notification.py @@ -0,0 +1,53 @@ +# +# 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 oslo.config import cfg + +from heat.openstack.common import log +from heat.openstack.common.notifier import api as notifier_api +from heat.engine import api as engine_api + +LOG = log.getLogger(__name__) +SERVICE = 'orchestration' +CONF = cfg.CONF +CONF.import_opt('default_notification_level', + 'heat.openstack.common.notifier.api') +CONF.import_opt('default_publisher_id', + 'heat.openstack.common.notifier.api') + + +def send(stack): + """Send usage notifications to the configured notification driver.""" + + publisher_id = CONF.default_publisher_id + if publisher_id is None: + publisher_id = notifier_api.publisher_id(SERVICE) + + # The current notifications have a start/end: + # see: https://wiki.openstack.org/wiki/SystemUsageData + # so to be consistant we translate our status into a known start/end/error + # suffix. + level = CONF.default_notification_level.upper() + if stack.status == stack.IN_PROGRESS: + suffix = 'start' + elif stack.status == stack.COMPLETE: + suffix = 'end' + else: + suffix = 'error' + level = notifier_api.ERROR + + event_type = '%s.%s.%s' % (SERVICE, stack.action.lower(), suffix) + + notifier_api.notify(stack.context, publisher_id, + event_type, level, + engine_api.format_notification_body(stack)) diff --git a/heat/engine/parser.py b/heat/engine/parser.py index 9e7f98891..ea89a2f76 100644 --- a/heat/engine/parser.py +++ b/heat/engine/parser.py @@ -23,6 +23,7 @@ from heat.engine import environment from heat.common import exception from heat.engine import dependencies from heat.common import identifier +from heat.engine import notification from heat.engine import resource from heat.engine import resources from heat.engine import scheduler @@ -348,6 +349,7 @@ class Stack(collections.Mapping): stack.update_and_save({'action': action, 'status': status, 'status_reason': reason}) + notification.send(self) @property def state(self): diff --git a/heat/rpc/api.py b/heat/rpc/api.py index cb2e57f82..b5e742377 100644 --- a/heat/rpc/api.py +++ b/heat/rpc/api.py @@ -82,6 +82,24 @@ EVENT_KEYS = ( 'resource_properties', ) +NOTIFY_KEYS = ( + NOTIFY_TENANT_ID, + NOTIFY_USER_ID, + NOTIFY_STACK_ID, + NOTIFY_STACK_NAME, + NOTIFY_STATE, + NOTIFY_STATE_REASON, + NOTIFY_CREATE_AT, +) = ( + 'tenant_id', + 'user_id', + STACK_ID, + STACK_NAME, + 'state', + 'state_reason', + 'create_at', +) + # This is the representation of a watch we expose to the API via RPC WATCH_KEYS = ( WATCH_ACTIONS_ENABLED, WATCH_ALARM_ACTIONS, WATCH_TOPIC, diff --git a/heat/tests/test_autoscaling_update_policy.py b/heat/tests/test_autoscaling_update_policy.py index 843f22d4a..5c7006395 100644 --- a/heat/tests/test_autoscaling_update_policy.py +++ b/heat/tests/test_autoscaling_update_policy.py @@ -20,6 +20,7 @@ from oslo.config import cfg from heat.common import exception from heat.common import template_format +from heat.engine import notification from heat.engine import parser from heat.engine.resources import user from heat.engine.resources import instance @@ -242,6 +243,9 @@ class AutoScalingGroupTest(HeatTestCase): self.m.StubOutWithMock(instance.Instance, 'handle_create') self.m.StubOutWithMock(instance.Instance, 'check_create_complete') + self.m.StubOutWithMock(notification, 'send') + notification.send(mox.IgnoreArg()).MultipleTimes().AndReturn(None) + cookie = object() # for load balancer setup @@ -266,6 +270,9 @@ class AutoScalingGroupTest(HeatTestCase): # for load balancer setup self._stub_lb_reload(num_reloads_expected_on_updt) + self.m.StubOutWithMock(notification, 'send') + notification.send(mox.IgnoreArg()).MultipleTimes().AndReturn(None) + # for instances in the group self.m.StubOutWithMock(instance.Instance, 'handle_create') self.m.StubOutWithMock(instance.Instance, 'check_create_complete') diff --git a/heat/tests/test_notifications.py b/heat/tests/test_notifications.py new file mode 100644 index 000000000..9f371c5b9 --- /dev/null +++ b/heat/tests/test_notifications.py @@ -0,0 +1,145 @@ +# 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. + +import mock + +from oslo.config import cfg + +from heat.openstack.common import timeutils + +from heat.engine import environment +from heat.engine import parser +from heat.engine import resource + +from heat.tests import generic_resource +from heat.tests import utils +from heat.tests import common + + +class NotificationTest(common.HeatTestCase): + + def setUp(self): + super(NotificationTest, self).setUp() + utils.setup_dummy_db() + + cfg.CONF.import_opt('notification_driver', + 'heat.openstack.common.notifier.api') + + cfg.CONF.set_default('notification_driver', + ['heat.openstack.common.notifier.test_notifier']) + cfg.CONF.set_default('host', 'test_host') + resource._register_class('GenericResource', + generic_resource.ResourceWithProps) + + def create_test_stack(self): + test_template = {'Parameters': {'Foo': {'Type': 'String'}, + 'Pass': {'Type': 'String', + 'NoEcho': True}}, + 'Resources': + {'TestResource': {'Type': 'GenericResource', + 'Properties': {'Foo': 'abc'}}}, + 'Outputs': {'food': + {'Value': + {'Fn::GetAtt': ['TestResource', + 'foo']}}}} + template = parser.Template(test_template) + self.ctx = utils.dummy_context() + self.ctx.tenant_id = 'test_tenant' + + env = environment.Environment() + env.load({u'parameters': + {u'Foo': 'user_data', u'Pass': 'secret'}}) + self.stack_name = utils.random_name() + stack = parser.Stack(self.ctx, self.stack_name, template, + env=env, disable_rollback=True) + self.stack = stack + stack.store() + self.created_time = stack.created_time + self.create_at = timeutils.isotime(self.created_time) + stack.create() + + self.expected = {} + for action in ('create', 'suspend', 'delete'): + self.make_mocks(action) + + def make_mocks(self, action): + stack_arn = self.stack.identifier().arn() + self.expected[action] = [ + mock.call(self.ctx, + 'orchestration.test_host', + 'orchestration.%s.start' % action, + 'INFO', + {'state_reason': 'Stack %s started' % action.upper(), + 'user_id': 'test_username', + 'stack_identity': stack_arn, + 'tenant_id': 'test_tenant', + 'create_at': self.create_at, + 'stack_name': self.stack_name, + 'state': '%s_IN_PROGRESS' % action.upper()}), + mock.call(self.ctx, 'orchestration.test_host', + 'orchestration.%s.end' % action, + 'INFO', + {'state_reason': + 'Stack %s completed successfully' % action, + 'user_id': 'test_username', + 'stack_identity': stack_arn, + 'tenant_id': 'test_tenant', + 'create_at': self.create_at, + 'stack_name': self.stack_name, + 'state': '%s_COMPLETE' % action.upper()})] + + @utils.stack_delete_after + def test_create_stack(self): + with mock.patch('heat.openstack.common.notifier.api.notify') \ + as mock_notify: + self.create_test_stack() + self.assertEqual(self.stack.state, (self.stack.CREATE, + self.stack.COMPLETE)) + + self.assertEqual(self.expected['create'], + mock_notify.call_args_list) + + @utils.stack_delete_after + def test_create_and_suspend_stack(self): + with mock.patch('heat.openstack.common.notifier.api.notify') \ + as mock_notify: + self.create_test_stack() + self.assertEqual(self.stack.state, (self.stack.CREATE, + self.stack.COMPLETE)) + + self.assertEqual(self.expected['create'], + mock_notify.call_args_list) + self.stack.suspend() + self.assertEqual(self.stack.state, (self.stack.SUSPEND, + self.stack.COMPLETE)) + expected = self.expected['create'] + self.expected['suspend'] + + self.assertEqual(expected, + mock_notify.call_args_list) + + @utils.stack_delete_after + def test_create_and_delete_stack(self): + with mock.patch('heat.openstack.common.notifier.api.notify') \ + as mock_notify: + self.create_test_stack() + self.assertEqual(self.stack.state, (self.stack.CREATE, + self.stack.COMPLETE)) + + self.assertEqual(self.expected['create'], + mock_notify.call_args_list) + self.stack.delete() + self.assertEqual(self.stack.state, (self.stack.DELETE, + self.stack.COMPLETE)) + expected = self.expected['create'] + self.expected['delete'] + + self.assertEqual(expected, + mock_notify.call_args_list)