From 0b44492da7b71a737f065a5065c0b3b661d6b0a4 Mon Sep 17 00:00:00 2001 From: Alexander Chadin Date: Fri, 21 Apr 2017 18:19:46 +0300 Subject: [PATCH] New cron type for audit interval This patch set adds cron supporting for audit. Implements: blueprint cron-based-continuous-audits Change-Id: I8570bebb13332dfba80185e912aeda45b6b4cd70 --- requirements.txt | 1 + watcher/api/controllers/v1/audit.py | 15 +++-- watcher/api/controllers/v1/types.py | 22 +++++++ watcher/common/exception.py | 8 +++ watcher/common/utils.py | 12 ++++ ...y => 0f6042416884_add_apscheduler_jobs.py} | 0 .../d098df6021e2_cron_support_for_audit.py | 26 ++++++++ watcher/db/sqlalchemy/models.py | 3 +- watcher/decision_engine/audit/continuous.py | 65 +++++++++++++++++-- watcher/notifications/audit.py | 33 ++++++++-- watcher/objects/audit.py | 10 ++- watcher/tests/api/v1/test_audits.py | 60 +++++++++++++++-- watcher/tests/db/utils.py | 5 +- .../audit/test_audit_handlers.py | 58 +++++++++++++++-- .../test_action_plan_notification.py | 20 ++++-- .../notifications/test_audit_notification.py | 24 +++++-- .../tests/notifications/test_notification.py | 12 ++-- watcher/tests/objects/test_objects.py | 2 +- .../tests/api/admin/base.py | 2 +- .../tests/api/admin/test_audit.py | 2 +- 20 files changed, 326 insertions(+), 54 deletions(-) rename watcher/db/sqlalchemy/alembic/versions/{0f6042416884_.py => 0f6042416884_add_apscheduler_jobs.py} (100%) create mode 100644 watcher/db/sqlalchemy/alembic/versions/d098df6021e2_cron_support_for_audit.py diff --git a/requirements.txt b/requirements.txt index c1bd6e978..bb8f570c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ keystoneauth1>=2.21.0 # Apache-2.0 jsonschema!=2.5.0,<3.0.0,>=2.0.0 # MIT keystonemiddleware>=4.12.0 # Apache-2.0 lxml!=3.7.0,>=2.3 # BSD +croniter>=0.3.4 # MIT License oslo.concurrency>=3.8.0 # Apache-2.0 oslo.cache>=1.5.0 # Apache-2.0 oslo.config!=4.3.0,!=4.4.0,>=4.0.0 # Apache-2.0 diff --git a/watcher/api/controllers/v1/audit.py b/watcher/api/controllers/v1/audit.py index fe29325ab..f654c63cf 100644 --- a/watcher/api/controllers/v1/audit.py +++ b/watcher/api/controllers/v1/audit.py @@ -65,7 +65,7 @@ class AuditPostType(wtypes.Base): parameters = wtypes.wsattr({wtypes.text: types.jsontype}, mandatory=False, default={}) - interval = wsme.wsattr(int, mandatory=False) + interval = wsme.wsattr(types.interval_or_cron, mandatory=False) scope = wtypes.wsattr(types.jsontype, readonly=True) @@ -261,7 +261,7 @@ class Audit(base.APIBase): links = wsme.wsattr([link.Link], readonly=True) """A list containing a self link and associated audit links""" - interval = wsme.wsattr(int, mandatory=False) + interval = wsme.wsattr(wtypes.text, mandatory=False) """Launch audit periodically (in seconds)""" scope = wsme.wsattr(types.jsontype, mandatory=False) @@ -270,6 +270,9 @@ class Audit(base.APIBase): auto_trigger = wsme.wsattr(bool, mandatory=False, default=False) """Autoexecute action plan once audit is succeeded""" + next_run_time = wsme.wsattr(datetime.datetime, mandatory=False) + """The next time audit launch""" + def __init__(self, **kwargs): self.fields = [] fields = list(objects.Audit.fields) @@ -301,7 +304,8 @@ class Audit(base.APIBase): audit.unset_fields_except(['uuid', 'audit_type', 'state', 'goal_uuid', 'interval', 'scope', 'strategy_uuid', 'goal_name', - 'strategy_name', 'auto_trigger']) + 'strategy_name', 'auto_trigger', + 'next_run_time']) audit.links = [link.Link.make_link('self', url, 'audits', audit.uuid), @@ -325,9 +329,10 @@ class Audit(base.APIBase): created_at=datetime.datetime.utcnow(), deleted_at=None, updated_at=datetime.datetime.utcnow(), - interval=7200, + interval='7200', scope=[], - auto_trigger=False) + auto_trigger=False, + next_run_time=datetime.datetime.utcnow()) sample.goal_id = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae' sample.strategy_id = '7ae81bb3-dec3-4289-8d6c-da80bd8001ff' diff --git a/watcher/api/controllers/v1/types.py b/watcher/api/controllers/v1/types.py index 2c27254b8..77d41b633 100644 --- a/watcher/api/controllers/v1/types.py +++ b/watcher/api/controllers/v1/types.py @@ -43,6 +43,28 @@ class UuidOrNameType(wtypes.UserType): return UuidOrNameType.validate(value) +class IntervalOrCron(wtypes.UserType): + """A simple int value or cron syntax type""" + + basetype = wtypes.text + name = 'interval_or_cron' + + @staticmethod + def validate(value): + if not (utils.is_int_like(value) or utils.is_cron_like(value)): + raise exception.InvalidIntervalOrCron(name=value) + return value + + @staticmethod + def frombasetype(value): + if value is None: + return None + return IntervalOrCron.validate(value) + + +interval_or_cron = IntervalOrCron() + + class NameType(wtypes.UserType): """A simple logical name type.""" diff --git a/watcher/common/exception.py b/watcher/common/exception.py index 2e08020e8..b3b350628 100644 --- a/watcher/common/exception.py +++ b/watcher/common/exception.py @@ -202,6 +202,10 @@ class InvalidUuidOrName(Invalid): msg_fmt = _("Expected a logical name or uuid but received %(name)s") +class InvalidIntervalOrCron(Invalid): + msg_fmt = _("Expected an interval or cron syntax but received %(name)s") + + class GoalNotFound(ResourceNotFound): msg_fmt = _("Goal %(goal)s could not be found") @@ -418,6 +422,10 @@ class WildcardCharacterIsUsed(WatcherException): "wildcard character.") +class CronFormatIsInvalid(WatcherException): + msg_fmt = _("Provided cron is invalid: %(message)s") + + # Model class ComputeResourceNotFound(WatcherException): diff --git a/watcher/common/utils.py b/watcher/common/utils.py index ae4438f39..be345af4a 100644 --- a/watcher/common/utils.py +++ b/watcher/common/utils.py @@ -16,8 +16,11 @@ """Utilities and helper functions.""" +import datetime import re +from croniter import croniter + from jsonschema import validators from oslo_log import log as logging from oslo_utils import strutils @@ -63,6 +66,15 @@ is_int_like = strutils.is_int_like strtime = timeutils.strtime +def is_cron_like(value): + """Return True is submitted value is like cron syntax""" + try: + croniter(value, datetime.datetime.now()) + except Exception as e: + raise exception.CronFormatIsInvalid(message=str(e)) + return True + + def safe_rstrip(value, chars=None): """Removes trailing characters from a string if that does not make it empty diff --git a/watcher/db/sqlalchemy/alembic/versions/0f6042416884_.py b/watcher/db/sqlalchemy/alembic/versions/0f6042416884_add_apscheduler_jobs.py similarity index 100% rename from watcher/db/sqlalchemy/alembic/versions/0f6042416884_.py rename to watcher/db/sqlalchemy/alembic/versions/0f6042416884_add_apscheduler_jobs.py diff --git a/watcher/db/sqlalchemy/alembic/versions/d098df6021e2_cron_support_for_audit.py b/watcher/db/sqlalchemy/alembic/versions/d098df6021e2_cron_support_for_audit.py new file mode 100644 index 000000000..1fae4e823 --- /dev/null +++ b/watcher/db/sqlalchemy/alembic/versions/d098df6021e2_cron_support_for_audit.py @@ -0,0 +1,26 @@ +"""Add cron support for audit table + +Revision ID: d098df6021e2 +Revises: 0f6042416884 +Create Date: 2017-06-08 16:21:35.746752 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'd098df6021e2' +down_revision = '0f6042416884' + + +def upgrade(): + op.alter_column('audits', 'interval', existing_type=sa.String(36), + nullable=True) + op.add_column('audits', + sa.Column('next_run_time', sa.DateTime(), nullable=True)) + + +def downgrade(): + op.alter_column('audits', 'interval', existing_type=sa.Integer(), + nullable=True) + op.drop_column('audits', 'next_run_time') diff --git a/watcher/db/sqlalchemy/models.py b/watcher/db/sqlalchemy/models.py index 4f90f7b2c..dbe972b65 100644 --- a/watcher/db/sqlalchemy/models.py +++ b/watcher/db/sqlalchemy/models.py @@ -173,11 +173,12 @@ class Audit(Base): audit_type = Column(String(20)) state = Column(String(20), nullable=True) parameters = Column(JSONEncodedDict, nullable=True) - interval = Column(Integer, nullable=True) + interval = Column(String(36), nullable=True) goal_id = Column(Integer, ForeignKey('goals.id'), nullable=False) strategy_id = Column(Integer, ForeignKey('strategies.id'), nullable=True) scope = Column(JSONEncodedList, nullable=True) auto_trigger = Column(Boolean, nullable=False) + next_run_time = Column(DateTime, nullable=True) goal = orm.relationship(Goal, foreign_keys=goal_id, lazy=None) strategy = orm.relationship(Strategy, foreign_keys=strategy_id, lazy=None) diff --git a/watcher/decision_engine/audit/continuous.py b/watcher/decision_engine/audit/continuous.py index 966d40efc..2afcafe69 100644 --- a/watcher/decision_engine/audit/continuous.py +++ b/watcher/decision_engine/audit/continuous.py @@ -19,11 +19,14 @@ import datetime +from dateutil import tz from apscheduler.jobstores import memory +from croniter import croniter from watcher.common import context from watcher.common import scheduling +from watcher.common import utils from watcher import conf from watcher.db.sqlalchemy import api as sq_api from watcher.db.sqlalchemy import job_store @@ -81,11 +84,38 @@ class ContinuousAuditHandler(base.AuditHandler): plan.save() return solution + def _next_cron_time(self, audit): + if utils.is_cron_like(audit.interval): + return croniter(audit.interval, datetime.datetime.utcnow() + ).get_next(datetime.datetime) + @classmethod def execute_audit(cls, audit, request_context): self = cls() if not self._is_audit_inactive(audit): - self.execute(audit, request_context) + try: + self.execute(audit, request_context) + except Exception: + raise + finally: + if utils.is_int_like(audit.interval): + audit.next_run_time = ( + datetime.datetime.utcnow() + + datetime.timedelta(seconds=int(audit.interval))) + else: + audit.next_run_time = self._next_cron_time(audit) + audit.save() + + def _add_job(self, trigger, audit, audit_context, **trigger_args): + time_var = 'next_run_time' if trigger_args.get( + 'next_run_time') else 'run_date' + # We should convert UTC time to local time without tzinfo + trigger_args[time_var] = trigger_args[time_var].replace( + tzinfo=tz.tzutc()).astimezone(tz.tzlocal()).replace(tzinfo=None) + self.scheduler.add_job(self.execute_audit, trigger, + args=[audit, audit_context], + name='execute_audit', + **trigger_args) def launch_audits_periodically(self): audit_context = context.RequestContext(is_admin=True) @@ -101,13 +131,34 @@ class ContinuousAuditHandler(base.AuditHandler): job.args for job in self.scheduler.get_jobs() if job.name == 'execute_audit'] for audit in audits: + # if audit is not presented in scheduled audits yet. if audit.uuid not in [arg[0].uuid for arg in scheduler_job_args]: - self.scheduler.add_job( - self.execute_audit, 'interval', - args=[audit, audit_context], - seconds=audit.interval, - name='execute_audit', - next_run_time=datetime.datetime.now()) + # if interval is provided with seconds + if utils.is_int_like(audit.interval): + # if audit has already been provided and we need + # to restore it after shutdown + if audit.next_run_time is not None: + old_run_time = audit.next_run_time + current = datetime.datetime.utcnow() + if old_run_time < current: + delta = datetime.timedelta( + seconds=(int(audit.interval) - ( + current - old_run_time).seconds % + int(audit.interval))) + audit.next_run_time = current + delta + next_run_time = audit.next_run_time + # if audit is new one + else: + next_run_time = datetime.datetime.utcnow() + self._add_job('interval', audit, audit_context, + seconds=int(audit.interval), + next_run_time=next_run_time) + + else: + audit.next_run_time = self._next_cron_time(audit) + self._add_job('date', audit, audit_context, + run_date=audit.next_run_time) + audit.save() def start(self): self.scheduler.add_job( diff --git a/watcher/notifications/audit.py b/watcher/notifications/audit.py index cc5878a20..83ec80de2 100644 --- a/watcher/notifications/audit.py +++ b/watcher/notifications/audit.py @@ -39,6 +39,8 @@ class TerseAuditPayload(notificationbase.NotificationPayloadBase): 'parameters': ('audit', 'parameters'), 'interval': ('audit', 'interval'), 'scope': ('audit', 'scope'), + 'auto_trigger': ('audit', 'auto_trigger'), + 'next_run_time': ('audit', 'next_run_time'), 'created_at': ('audit', 'created_at'), 'updated_at': ('audit', 'updated_at'), @@ -46,17 +48,22 @@ class TerseAuditPayload(notificationbase.NotificationPayloadBase): } # Version 1.0: Initial version - VERSION = '1.0' + # Version 1.1: Added 'auto_trigger' boolean field, + # Added 'next_run_time' DateTime field, + # 'interval' type has been changed from Integer to String + VERSION = '1.1' fields = { 'uuid': wfields.UUIDField(), 'audit_type': wfields.StringField(), 'state': wfields.StringField(), 'parameters': wfields.FlexibleDictField(nullable=True), - 'interval': wfields.IntegerField(nullable=True), + 'interval': wfields.StringField(nullable=True), 'scope': wfields.FlexibleListOfDictField(nullable=True), 'goal_uuid': wfields.UUIDField(), 'strategy_uuid': wfields.UUIDField(nullable=True), + 'auto_trigger': wfields.BooleanField(), + 'next_run_time': wfields.DateTimeField(nullable=True), 'created_at': wfields.DateTimeField(nullable=True), 'updated_at': wfields.DateTimeField(nullable=True), @@ -79,6 +86,8 @@ class AuditPayload(TerseAuditPayload): 'parameters': ('audit', 'parameters'), 'interval': ('audit', 'interval'), 'scope': ('audit', 'scope'), + 'auto_trigger': ('audit', 'auto_trigger'), + 'next_run_time': ('audit', 'next_run_time'), 'created_at': ('audit', 'created_at'), 'updated_at': ('audit', 'updated_at'), @@ -86,7 +95,9 @@ class AuditPayload(TerseAuditPayload): } # Version 1.0: Initial version - VERSION = '1.0' + # Version 1.1: Added 'auto_trigger' field, + # Added 'next_run_time' field + VERSION = '1.1' fields = { 'goal': wfields.ObjectField('GoalPayload'), @@ -119,7 +130,9 @@ class AuditStateUpdatePayload(notificationbase.NotificationPayloadBase): @base.WatcherObjectRegistry.register_notification class AuditCreatePayload(AuditPayload): # Version 1.0: Initial version - VERSION = '1.0' + # Version 1.1: Added 'auto_trigger' field, + # Added 'next_run_time' field + VERSION = '1.1' fields = {} def __init__(self, audit, goal, strategy): @@ -133,7 +146,9 @@ class AuditCreatePayload(AuditPayload): @base.WatcherObjectRegistry.register_notification class AuditUpdatePayload(AuditPayload): # Version 1.0: Initial version - VERSION = '1.0' + # Version 1.1: Added 'auto_trigger' field, + # Added 'next_run_time' field + VERSION = '1.1' fields = { 'state_update': wfields.ObjectField('AuditStateUpdatePayload'), } @@ -150,7 +165,9 @@ class AuditUpdatePayload(AuditPayload): @base.WatcherObjectRegistry.register_notification class AuditActionPayload(AuditPayload): # Version 1.0: Initial version - VERSION = '1.0' + # Version 1.1: Added 'auto_trigger' field, + # Added 'next_run_time' field + VERSION = '1.1' fields = { 'fault': wfields.ObjectField('ExceptionPayload', nullable=True), } @@ -167,7 +184,9 @@ class AuditActionPayload(AuditPayload): @base.WatcherObjectRegistry.register_notification class AuditDeletePayload(AuditPayload): # Version 1.0: Initial version - VERSION = '1.0' + # Version 1.1: Added 'auto_trigger' field, + # Added 'next_run_time' field + VERSION = '1.1' fields = {} def __init__(self, audit, goal, strategy): diff --git a/watcher/objects/audit.py b/watcher/objects/audit.py index fa9bdd77b..d0a1c100e 100644 --- a/watcher/objects/audit.py +++ b/watcher/objects/audit.py @@ -83,8 +83,10 @@ class Audit(base.WatcherPersistentObject, base.WatcherObject, # Version 1.0: Initial version # Version 1.1: Added 'goal' and 'strategy' object field - # Version 1.2 Added 'auto_trigger' boolean field - VERSION = '1.2' + # Version 1.2: Added 'auto_trigger' boolean field + # Version 1.3: Added 'next_run_time' DateTime field, + # 'interval' type has been changed from Integer to String + VERSION = '1.3' dbapi = db_api.get_instance() @@ -94,11 +96,13 @@ class Audit(base.WatcherPersistentObject, base.WatcherObject, 'audit_type': wfields.StringField(), 'state': wfields.StringField(), 'parameters': wfields.FlexibleDictField(nullable=True), - 'interval': wfields.IntegerField(nullable=True), + 'interval': wfields.StringField(nullable=True), 'scope': wfields.FlexibleListOfDictField(nullable=True), 'goal_id': wfields.IntegerField(), 'strategy_id': wfields.IntegerField(nullable=True), 'auto_trigger': wfields.BooleanField(), + 'next_run_time': wfields.DateTimeField(nullable=True, + tzinfo_aware=False), 'goal': wfields.ObjectField('Goal', nullable=True), 'strategy': wfields.ObjectField('Strategy', nullable=True), diff --git a/watcher/tests/api/v1/test_audits.py b/watcher/tests/api/v1/test_audits.py index a42e333eb..b26f62cd1 100644 --- a/watcher/tests/api/v1/test_audits.py +++ b/watcher/tests/api/v1/test_audits.py @@ -481,6 +481,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -523,6 +524,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] # Make the audit template UUID some garbage value audit_dict['audit_template_uuid'] = ( '01234567-8910-1112-1314-151617181920') @@ -545,6 +547,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] with mock.patch.object(self.dbapi, 'create_audit', wraps=self.dbapi.create_audit) as cn_mock: response = self.post_json('/audits', audit_dict) @@ -562,6 +565,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -571,15 +575,16 @@ class TestPost(api_base.FunctionalTest): self.assertTrue(utils.is_uuid_like(response.json['uuid'])) @mock.patch.object(deapi.DecisionEngineAPI, 'trigger_audit') - def test_create_continuous_audit_with_period(self, mock_trigger_audit): + def test_create_continuous_audit_with_interval(self, mock_trigger_audit): mock_trigger_audit.return_value = mock.ANY audit_dict = post_get_test_audit() del audit_dict['uuid'] del audit_dict['state'] del audit_dict['scope'] + del audit_dict['next_run_time'] audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value - audit_dict['interval'] = 1200 + audit_dict['interval'] = '1200' response = self.post_json('/audits', audit_dict) self.assertEqual('application/json', response.content_type) @@ -589,6 +594,48 @@ class TestPost(api_base.FunctionalTest): self.assertEqual(audit_dict['interval'], response.json['interval']) self.assertTrue(utils.is_uuid_like(response.json['uuid'])) + @mock.patch.object(deapi.DecisionEngineAPI, 'trigger_audit') + def test_create_continuous_audit_with_cron_interval(self, + mock_trigger_audit): + mock_trigger_audit.return_value = mock.ANY + + audit_dict = post_get_test_audit() + del audit_dict['uuid'] + del audit_dict['state'] + del audit_dict['scope'] + del audit_dict['next_run_time'] + audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value + audit_dict['interval'] = '* * * * *' + + response = self.post_json('/audits', audit_dict) + self.assertEqual('application/json', response.content_type) + self.assertEqual(201, response.status_int) + self.assertEqual(objects.audit.State.PENDING, + response.json['state']) + self.assertEqual(audit_dict['interval'], response.json['interval']) + self.assertTrue(utils.is_uuid_like(response.json['uuid'])) + + @mock.patch.object(deapi.DecisionEngineAPI, 'trigger_audit') + def test_create_continuous_audit_with_wrong_interval(self, + mock_trigger_audit): + mock_trigger_audit.return_value = mock.ANY + + audit_dict = post_get_test_audit() + del audit_dict['uuid'] + del audit_dict['state'] + del audit_dict['scope'] + del audit_dict['next_run_time'] + audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value + audit_dict['interval'] = 'zxc' + + response = self.post_json('/audits', audit_dict, expect_errors=True) + self.assertEqual('application/json', response.content_type) + self.assertEqual(500, response.status_int) + expected_error_msg = ('Exactly 5 or 6 columns has to be ' + 'specified for iteratorexpression.') + self.assertTrue(response.json['error_message']) + self.assertIn(expected_error_msg, response.json['error_message']) + @mock.patch.object(deapi.DecisionEngineAPI, 'trigger_audit') def test_create_continuous_audit_without_period(self, mock_trigger_audit): mock_trigger_audit.return_value = mock.ANY @@ -599,6 +646,7 @@ class TestPost(api_base.FunctionalTest): audit_dict['audit_type'] = objects.audit.AuditType.CONTINUOUS.value del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict, expect_errors=True) self.assertEqual(400, response.status_int) @@ -616,8 +664,8 @@ class TestPost(api_base.FunctionalTest): del audit_dict['uuid'] del audit_dict['state'] audit_dict['audit_type'] = objects.audit.AuditType.ONESHOT.value - audit_dict['interval'] = 1200 del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict, expect_errors=True) self.assertEqual(400, response.status_int) @@ -634,6 +682,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict) de_mock.assert_called_once_with(mock.ANY, response.json['uuid']) @@ -657,6 +706,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict, expect_errors=True) self.assertEqual('application/json', response.content_type) @@ -678,6 +728,7 @@ class TestPost(api_base.FunctionalTest): del audit_dict['state'] del audit_dict['interval'] del audit_dict['scope'] + del audit_dict['next_run_time'] response = self.post_json('/audits', audit_dict, expect_errors=True) self.assertEqual('application/json', response.content_type) @@ -700,7 +751,7 @@ class TestPost(api_base.FunctionalTest): audit_dict['audit_template_uuid'] = audit_template['uuid'] del_keys = ['uuid', 'goal_id', 'strategy_id', 'state', 'interval', - 'scope'] + 'scope', 'next_run_time'] for k in del_keys: del audit_dict[k] @@ -839,6 +890,7 @@ class TestAuditPolicyEnforcement(api_base.FunctionalTest): del audit_dict['uuid'] del audit_dict['state'] del audit_dict['scope'] + del audit_dict['next_run_time'] self._common_policy_check( "audit:create", self.post_json, '/audits', audit_dict, expect_errors=True) diff --git a/watcher/tests/db/utils.py b/watcher/tests/db/utils.py index 4cb66170a..65b88c6d8 100644 --- a/watcher/tests/db/utils.py +++ b/watcher/tests/db/utils.py @@ -89,11 +89,12 @@ def get_test_audit(**kwargs): 'updated_at': kwargs.get('updated_at'), 'deleted_at': kwargs.get('deleted_at'), 'parameters': kwargs.get('parameters', {}), - 'interval': kwargs.get('interval', 3600), + 'interval': kwargs.get('interval', '3600'), 'goal_id': kwargs.get('goal_id', 1), 'strategy_id': kwargs.get('strategy_id', None), 'scope': kwargs.get('scope', []), - 'auto_trigger': kwargs.get('auto_trigger', False) + 'auto_trigger': kwargs.get('auto_trigger', False), + 'next_run_time': kwargs.get('next_run_time') } # ObjectField doesn't allow None nor dict, so if we want to simulate a # non-eager object loading, the field should not be referenced at all. diff --git a/watcher/tests/decision_engine/audit/test_audit_handlers.py b/watcher/tests/decision_engine/audit/test_audit_handlers.py index f992aa268..ed5ca9651 100644 --- a/watcher/tests/decision_engine/audit/test_audit_handlers.py +++ b/watcher/tests/decision_engine/audit/test_audit_handlers.py @@ -14,12 +14,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime + import mock from oslo_utils import uuidutils from apscheduler import job from watcher.applier import rpcapi +from watcher.common import exception from watcher.common import scheduling from watcher.db.sqlalchemy import api as sq_api from watcher.decision_engine.audit import continuous @@ -241,20 +244,65 @@ class TestContinuousAuditHandler(base.DbTestCase): @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(objects.audit.Audit, 'list') - def test_launch_audits_periodically(self, mock_list, mock_jobs, - m_add_job, m_engine, m_service): + def test_launch_audits_periodically_with_interval( + self, mock_list, mock_jobs, m_add_job, m_engine, m_service): audit_handler = continuous.ContinuousAuditHandler() mock_list.return_value = self.audits + self.audits[0].next_run_time = (datetime.datetime.now() - + datetime.timedelta(seconds=1800)) mock_jobs.return_value = mock.MagicMock() m_engine.return_value = mock.MagicMock() - m_add_job.return_value = audit_handler.execute_audit( - self.audits[0], self.context) + m_add_job.return_value = mock.MagicMock() audit_handler.launch_audits_periodically() m_service.assert_called() m_engine.assert_called() m_add_job.assert_called() mock_jobs.assert_called() + self.assertIsNotNone(self.audits[0].next_run_time) + self.assertIsNone(self.audits[1].next_run_time) + + @mock.patch.object(objects.service.Service, 'list') + @mock.patch.object(sq_api, 'get_engine') + @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') + @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') + @mock.patch.object(objects.audit.Audit, 'list') + def test_launch_audits_periodically_with_cron( + self, mock_list, mock_jobs, m_add_job, m_engine, m_service): + audit_handler = continuous.ContinuousAuditHandler() + mock_list.return_value = self.audits + self.audits[0].interval = "*/5 * * * *" + mock_jobs.return_value = mock.MagicMock() + m_engine.return_value = mock.MagicMock() + m_add_job.return_value = mock.MagicMock() + + audit_handler.launch_audits_periodically() + m_service.assert_called() + m_engine.assert_called() + m_add_job.assert_called() + mock_jobs.assert_called() + self.assertIsNotNone(self.audits[0].next_run_time) + self.assertIsNone(self.audits[1].next_run_time) + + @mock.patch.object(continuous.ContinuousAuditHandler, '_next_cron_time') + @mock.patch.object(objects.service.Service, 'list') + @mock.patch.object(sq_api, 'get_engine') + @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') + @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') + @mock.patch.object(objects.audit.Audit, 'list') + def test_launch_audits_periodically_with_invalid_cron( + self, mock_list, mock_jobs, m_add_job, m_engine, m_service, + mock_cron): + audit_handler = continuous.ContinuousAuditHandler() + mock_list.return_value = self.audits + self.audits[0].interval = "*/5* * * *" + mock_cron.side_effect = exception.CronFormatIsInvalid + mock_jobs.return_value = mock.MagicMock() + m_engine.return_value = mock.MagicMock() + m_add_job.return_value = mock.MagicMock() + + self.assertRaises(exception.CronFormatIsInvalid, + audit_handler.launch_audits_periodically) @mock.patch.object(objects.service.Service, 'list') @mock.patch.object(sq_api, 'get_engine') @@ -273,7 +321,7 @@ class TestContinuousAuditHandler(base.DbTestCase): args=[mock.ANY, mock.ANY], seconds=3600, name='execute_audit', - next_run_time=mock.ANY) for audit in self.audits] + next_run_time=mock.ANY) for _ in self.audits] audit_handler.launch_audits_periodically() m_add_job.assert_has_calls(calls) diff --git a/watcher/tests/notifications/test_action_plan_notification.py b/watcher/tests/notifications/test_action_plan_notification.py index b3e35ae5e..47dce1f61 100644 --- a/watcher/tests/notifications/test_action_plan_notification.py +++ b/watcher/tests/notifications/test_action_plan_notification.py @@ -94,6 +94,8 @@ class TestActionPlanNotification(base.DbTestCase): "audit": { "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "uuid": "10a47dd1-4874-4298-91cf-eff046dbdb8d", "strategy_uuid": None, @@ -108,7 +110,7 @@ class TestActionPlanNotification(base.DbTestCase): }, "watcher_object.name": "TerseAuditPayload", "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0" + "watcher_object.version": "1.1" }, "deleted_at": None, "state": "ONGOING", @@ -168,6 +170,8 @@ class TestActionPlanNotification(base.DbTestCase): "audit": { "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "uuid": "10a47dd1-4874-4298-91cf-eff046dbdb8d", "strategy_uuid": None, @@ -182,7 +186,7 @@ class TestActionPlanNotification(base.DbTestCase): }, "watcher_object.name": "TerseAuditPayload", "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0" + "watcher_object.version": "1.1" }, "deleted_at": None, "state": "PENDING", @@ -234,6 +238,8 @@ class TestActionPlanNotification(base.DbTestCase): "audit": { "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "uuid": "10a47dd1-4874-4298-91cf-eff046dbdb8d", "strategy_uuid": None, @@ -248,7 +254,7 @@ class TestActionPlanNotification(base.DbTestCase): }, "watcher_object.name": "TerseAuditPayload", "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0" + "watcher_object.version": "1.1" }, "deleted_at": None, "state": "DELETED", @@ -287,9 +293,11 @@ class TestActionPlanNotification(base.DbTestCase): "audit": { "watcher_object.namespace": "watcher", "watcher_object.name": "TerseAuditPayload", - "watcher_object.version": "1.0", + "watcher_object.version": "1.1", "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "uuid": "10a47dd1-4874-4298-91cf-eff046dbdb8d", "strategy_uuid": None, @@ -373,6 +381,8 @@ class TestActionPlanNotification(base.DbTestCase): "audit": { "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "uuid": "10a47dd1-4874-4298-91cf-eff046dbdb8d", "strategy_uuid": None, @@ -387,7 +397,7 @@ class TestActionPlanNotification(base.DbTestCase): }, "watcher_object.name": "TerseAuditPayload", "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0" + "watcher_object.version": "1.1" }, "global_efficacy": {}, "state": "ONGOING", diff --git a/watcher/tests/notifications/test_audit_notification.py b/watcher/tests/notifications/test_audit_notification.py index 7fa29f8f2..0a5178a98 100644 --- a/watcher/tests/notifications/test_audit_notification.py +++ b/watcher/tests/notifications/test_audit_notification.py @@ -69,9 +69,11 @@ class TestAuditNotification(base.DbTestCase): self.assertDictEqual( { "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0", + "watcher_object.version": "1.1", "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "strategy_uuid": "cb3d0b58-4415-4d90-b75b-1e96878730e3", "strategy": { "watcher_object.namespace": "watcher", @@ -141,9 +143,11 @@ class TestAuditNotification(base.DbTestCase): self.assertDictEqual( { "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0", + "watcher_object.version": "1.1", "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "uuid": "10a47dd1-4874-4298-91cf-eff046dbdb8d", "goal_uuid": "f7ad87ae-4298-91cf-93a0-f35a852e3652", @@ -200,9 +204,11 @@ class TestAuditNotification(base.DbTestCase): self.assertDictEqual( { "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0", + "watcher_object.version": "1.1", "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "strategy_uuid": "cb3d0b58-4415-4d90-b75b-1e96878730e3", "strategy": { "watcher_object.namespace": "watcher", @@ -263,9 +269,11 @@ class TestAuditNotification(base.DbTestCase): self.assertDictEqual( { "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0", + "watcher_object.version": "1.1", "watcher_object.data": { "interval": None, + "next_run_time": None, + "auto_trigger": False, "strategy_uuid": "cb3d0b58-4415-4d90-b75b-1e96878730e3", "strategy": { "watcher_object.namespace": "watcher", @@ -350,6 +358,8 @@ class TestAuditNotification(base.DbTestCase): "watcher_object.version": "1.0" }, "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "scope": [], "state": "ONGOING", @@ -374,7 +384,7 @@ class TestAuditNotification(base.DbTestCase): }, "watcher_object.name": "AuditActionPayload", "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0" + "watcher_object.version": "1.1" } }, notification @@ -434,6 +444,8 @@ class TestAuditNotification(base.DbTestCase): "watcher_object.version": "1.0" }, "interval": None, + "next_run_time": None, + "auto_trigger": False, "parameters": {}, "scope": [], "state": "ONGOING", @@ -458,7 +470,7 @@ class TestAuditNotification(base.DbTestCase): }, "watcher_object.name": "AuditActionPayload", "watcher_object.namespace": "watcher", - "watcher_object.version": "1.0" + "watcher_object.version": "1.1" } }, notification diff --git a/watcher/tests/notifications/test_notification.py b/watcher/tests/notifications/test_notification.py index f4fe6595f..d60a14867 100644 --- a/watcher/tests/notifications/test_notification.py +++ b/watcher/tests/notifications/test_notification.py @@ -254,17 +254,17 @@ expected_notification_fingerprints = { 'ExceptionNotification': '1.0-9b69de0724fda8310d05e18418178866', 'ExceptionPayload': '1.0-4516ae282a55fe2fd5c754967ee6248b', 'NotificationPublisher': '1.0-bbbc1402fb0e443a3eb227cc52b61545', - 'TerseAuditPayload': '1.0-aaf31166b8698f08d12cae98c380b8e0', - 'AuditPayload': '1.0-30c85c834648c8ca11f54fc5e084d86b', + 'TerseAuditPayload': '1.1-19b0e9224c0953366418a30ed785f267', + 'AuditPayload': '1.1-4c59e0cc5d30c42d3b842ce0332709d5', 'AuditStateUpdatePayload': '1.0-1a1b606bf14a2c468800c2b010801ce5', 'AuditUpdateNotification': '1.0-9b69de0724fda8310d05e18418178866', - 'AuditUpdatePayload': '1.0-d3aace28d9eb978c1ecf833e108f61f7', + 'AuditUpdatePayload': '1.1-9b1f725e736051b976571701e5cc1e55', 'AuditCreateNotification': '1.0-9b69de0724fda8310d05e18418178866', - 'AuditCreatePayload': '1.0-30c85c834648c8ca11f54fc5e084d86b', + 'AuditCreatePayload': '1.1-4c59e0cc5d30c42d3b842ce0332709d5', 'AuditDeleteNotification': '1.0-9b69de0724fda8310d05e18418178866', - 'AuditDeletePayload': '1.0-30c85c834648c8ca11f54fc5e084d86b', + 'AuditDeletePayload': '1.1-4c59e0cc5d30c42d3b842ce0332709d5', 'AuditActionNotification': '1.0-9b69de0724fda8310d05e18418178866', - 'AuditActionPayload': '1.0-09f5d005f94ba9e5f6b9200170332c52', + 'AuditActionPayload': '1.1-5a43e7321495c19f98ef5663efa0a821', 'GoalPayload': '1.0-fa1fecb8b01dd047eef808ded4d50d1a', 'StrategyPayload': '1.0-94f01c137b083ac236ae82573c1fcfc1', 'ActionPlanActionPayload': '1.0-d9f134708e06cf2ff2d3b8d522ac2aa8', diff --git a/watcher/tests/objects/test_objects.py b/watcher/tests/objects/test_objects.py index 04c13e188..cc61f4681 100644 --- a/watcher/tests/objects/test_objects.py +++ b/watcher/tests/objects/test_objects.py @@ -412,7 +412,7 @@ expected_object_fingerprints = { 'Goal': '1.0-93881622db05e7b67a65ca885b4a022e', 'Strategy': '1.1-73f164491bdd4c034f48083a51bdeb7b', 'AuditTemplate': '1.1-b291973ffc5efa2c61b24fe34fdccc0b', - 'Audit': '1.2-910522db78b7b1cb59df614754656db4', + 'Audit': '1.3-f47ffb1ee79d8248eb991674bda565ce', 'ActionPlan': '2.0-394f1abbf5d73d7b6675a118fe1a0284', 'Action': '2.0-1dd4959a7e7ac30c62ef170fe08dd935', 'EfficacyIndicator': '1.0-655b71234a82bc7478aff964639c4bb0', diff --git a/watcher_tempest_plugin/tests/api/admin/base.py b/watcher_tempest_plugin/tests/api/admin/base.py index 75e57b9e2..2932dd44f 100644 --- a/watcher_tempest_plugin/tests/api/admin/base.py +++ b/watcher_tempest_plugin/tests/api/admin/base.py @@ -171,7 +171,7 @@ class BaseInfraOptimTest(test.BaseTestCase): :param audit_template_uuid: Audit Template UUID this audit will use :param audit_type: Audit type (either ONESHOT or CONTINUOUS) :param state: Audit state (str) - :param interval: Audit interval in seconds (int) + :param interval: Audit interval in seconds or cron syntax (str) :return: A tuple with The HTTP response and its body """ resp, body = cls.client.create_audit( diff --git a/watcher_tempest_plugin/tests/api/admin/test_audit.py b/watcher_tempest_plugin/tests/api/admin/test_audit.py index 222295081..13a187ea3 100644 --- a/watcher_tempest_plugin/tests/api/admin/test_audit.py +++ b/watcher_tempest_plugin/tests/api/admin/test_audit.py @@ -63,7 +63,7 @@ class TestCreateUpdateDeleteAudit(base.BaseInfraOptimTest): audit_params = dict( audit_template_uuid=audit_template['uuid'], audit_type='CONTINUOUS', - interval=7200, + interval='7200', ) _, body = self.create_audit(**audit_params)