From b1144b22ce9302a1ea621dfab4f760c3a8ed094d Mon Sep 17 00:00:00 2001 From: Ana Krivokapic Date: Tue, 2 Aug 2016 17:30:14 +0200 Subject: [PATCH] Add a modulo core constraint A modulo constraint will be used to restrict numeric values to leave a certain remainder when divided with certain divisor. E.g. we can use this to constrain values to even/odd numbers. Change-Id: I9d7db4307be2a2b93cc928cf5912af7b49c72076 --- doc/source/developing_guides/pluginguide.rst | 6 ++ doc/source/template_guide/hot_spec.rst | 18 ++++ heat/engine/api.py | 7 ++ heat/engine/constraints.py | 79 +++++++++++++++++ heat/engine/hot/parameters.py | 78 +++++++++++------ heat/engine/hot/template.py | 6 +- heat/rpc/api.py | 3 +- heat/tests/test_constraints.py | 89 ++++++++++++++++++++ heat/tests/test_hot.py | 59 +++++++++++++ 9 files changed, 315 insertions(+), 30 deletions(-) diff --git a/doc/source/developing_guides/pluginguide.rst b/doc/source/developing_guides/pluginguide.rst index 3babf5acb..1fd77baf3 100644 --- a/doc/source/developing_guides/pluginguide.rst +++ b/doc/source/developing_guides/pluginguide.rst @@ -251,6 +251,12 @@ the end user. Constrains a numerical value. Applicable to INTEGER and NUMBER. Both ``min`` and ``max`` default to ``None``. +*Modulo(step, offset, description)*: + Starting with the specified ``offset``, every multiple of ``step`` is a valid + value. Applicable to INTEGER and NUMBER. + + Available from template version 2017-02-24. + *CustomConstraint(name, description, environment)*: This constructor brings in a named constraint class from an environment. If the given environment is ``None`` (its default) diff --git a/doc/source/template_guide/hot_spec.rst b/doc/source/template_guide/hot_spec.rst index 8b60a4fc0..6dee9a7a5 100644 --- a/doc/source/template_guide/hot_spec.rst +++ b/doc/source/template_guide/hot_spec.rst @@ -529,6 +529,24 @@ following range constraint would allow for all numeric values between 0 and range: { min: 0, max: 10 } +modulo +++++++ +The ``modulo`` constraint applies to parameters of type ``number``. The value +is valid if it is a multiple of ``step``, starting with ``offset``. + +The syntax of the ``modulo`` constraint is + +.. code-block:: yaml + + modulo: { step: , offset: } + +Both ``step`` and ``offset`` must be specified. + +For example, the following modulo constraint would only allow for odd numbers + +.. code-block:: yaml + + modulo: { step: 2, offset: 1 } allowed_values ++++++++++++++ diff --git a/heat/engine/api.py b/heat/engine/api.py index ea3b2ffdd..f43f6b05b 100644 --- a/heat/engine/api.py +++ b/heat/engine/api.py @@ -541,6 +541,13 @@ def format_validate_parameter(param): if c.max is not None: res[rpc_api.PARAM_MAX_VALUE] = c.max + elif isinstance(c, constr.Modulo): + if c.step is not None: + res[rpc_api.PARAM_STEP] = c.step + + if c.offset is not None: + res[rpc_api.PARAM_OFFSET] = c.offset + elif isinstance(c, constr.AllowedValues): res[rpc_api.PARAM_ALLOWED_VALUES] = list(c.allowed) diff --git a/heat/engine/constraints.py b/heat/engine/constraints.py index d6a7c2033..89fbfd457 100644 --- a/heat/engine/constraints.py +++ b/heat/engine/constraints.py @@ -443,6 +443,85 @@ class Length(Range): template) +class Modulo(Constraint): + """Constrain values to modulo. + + Serializes to JSON as:: + + { + 'modulo': {'step': , 'offset': }, + 'description': + } + """ + + (STEP, OFFSET) = ('step', 'offset') + + valid_types = (Schema.INTEGER_TYPE, Schema.NUMBER_TYPE,) + + def __init__(self, step=None, offset=None, description=None): + super(Modulo, self).__init__(description) + self.step = step + self.offset = offset + + if step is None or offset is None: + raise exception.InvalidSchemaError( + message=_('A modulo constraint must have a step value and ' + 'an offset value specified.')) + + for param in (step, offset): + if not isinstance(param, (float, six.integer_types, type(None))): + raise exception.InvalidSchemaError( + message=_('step/offset must be numeric')) + + if not int(param) == param: + raise exception.InvalidSchemaError( + message=_('step/offset must be integer')) + + step, offset = int(step), int(offset) + + if step == 0: + raise exception.InvalidSchemaError(message=_('step cannot be 0.')) + + if abs(offset) >= abs(step): + raise exception.InvalidSchemaError( + message=_('offset must be smaller (by absolute value) ' + 'than step.')) + + if step * offset < 0: + raise exception.InvalidSchemaError( + message=_('step and offset must be both positive or both ' + 'negative.')) + + def _str(self): + if self.step is None or self.offset is None: + fmt = _('The values must be specified.') + else: + fmt = _('The value must be a multiple of %(step)s ' + 'with an offset of %(offset)s.') + return fmt % self._constraint() + + def _err_msg(self, value): + return '%s is not a multiple of %s with an offset of %s)' % ( + value, self.step, self.offset) + + def _is_valid(self, value, schema, context, template): + value = Schema.str_to_num(value) + + if value % self.step != self.offset: + return False + + return True + + def _constraint(self): + def constraints(): + if self.step is not None: + yield self.STEP, self.step + if self.offset is not None: + yield self.OFFSET, self.offset + + return dict(constraints()) + + class AllowedValues(Constraint): """Constrain values to a predefined set. diff --git a/heat/engine/hot/parameters.py b/heat/engine/hot/parameters.py index 03a914195..143828892 100644 --- a/heat/engine/hot/parameters.py +++ b/heat/engine/hot/parameters.py @@ -18,15 +18,17 @@ from heat.engine import parameters PARAM_CONSTRAINTS = ( - DESCRIPTION, LENGTH, RANGE, ALLOWED_VALUES, ALLOWED_PATTERN, + DESCRIPTION, LENGTH, RANGE, MODULO, ALLOWED_VALUES, ALLOWED_PATTERN, CUSTOM_CONSTRAINT, ) = ( - 'description', 'length', 'range', 'allowed_values', 'allowed_pattern', - 'custom_constraint', + 'description', 'length', 'range', 'modulo', 'allowed_values', + 'allowed_pattern', 'custom_constraint', ) RANGE_KEYS = (MIN, MAX) = ('min', 'max') +MODULO_KEYS = (STEP, OFFSET) = ('step', 'offset') + class HOTParamSchema(parameters.Schema): """HOT parameter schema.""" @@ -49,6 +51,34 @@ class HOTParamSchema(parameters.Schema): PARAMETER_KEYS = KEYS + @classmethod + def _constraint_from_def(cls, constraint): + desc = constraint.get(DESCRIPTION) + if RANGE in constraint: + cdef = constraint.get(RANGE) + cls._check_dict(cdef, RANGE_KEYS, 'range constraint') + return constr.Range(parameters.Schema.get_num(MIN, cdef), + parameters.Schema.get_num(MAX, cdef), + desc) + elif LENGTH in constraint: + cdef = constraint.get(LENGTH) + cls._check_dict(cdef, RANGE_KEYS, 'length constraint') + return constr.Length(parameters.Schema.get_num(MIN, cdef), + parameters.Schema.get_num(MAX, cdef), + desc) + elif ALLOWED_VALUES in constraint: + cdef = constraint.get(ALLOWED_VALUES) + return constr.AllowedValues(cdef, desc) + elif ALLOWED_PATTERN in constraint: + cdef = constraint.get(ALLOWED_PATTERN) + return constr.AllowedPattern(cdef, desc) + elif CUSTOM_CONSTRAINT in constraint: + cdef = constraint.get(CUSTOM_CONSTRAINT) + return constr.CustomConstraint(cdef, desc) + else: + raise exception.InvalidSchemaError( + message=_("No constraint expressed")) + @classmethod def from_dict(cls, param_name, schema_dict): """Return a Parameter Schema object from a legacy schema dictionary. @@ -72,31 +102,7 @@ class HOTParamSchema(parameters.Schema): for constraint in constraints: cls._check_dict(constraint, PARAM_CONSTRAINTS, 'parameter constraints') - desc = constraint.get(DESCRIPTION) - if RANGE in constraint: - cdef = constraint.get(RANGE) - cls._check_dict(cdef, RANGE_KEYS, 'range constraint') - yield constr.Range(parameters.Schema.get_num(MIN, cdef), - parameters.Schema.get_num(MAX, cdef), - desc) - elif LENGTH in constraint: - cdef = constraint.get(LENGTH) - cls._check_dict(cdef, RANGE_KEYS, 'length constraint') - yield constr.Length(parameters.Schema.get_num(MIN, cdef), - parameters.Schema.get_num(MAX, cdef), - desc) - elif ALLOWED_VALUES in constraint: - cdef = constraint.get(ALLOWED_VALUES) - yield constr.AllowedValues(cdef, desc) - elif ALLOWED_PATTERN in constraint: - cdef = constraint.get(ALLOWED_PATTERN) - yield constr.AllowedPattern(cdef, desc) - elif CUSTOM_CONSTRAINT in constraint: - cdef = constraint.get(CUSTOM_CONSTRAINT) - yield constr.CustomConstraint(cdef, desc) - else: - raise exception.InvalidSchemaError( - message=_("No constraint expressed")) + yield cls._constraint_from_def(constraint) # make update_allowed true by default on TemplateResources # as the template should deal with this. @@ -109,6 +115,22 @@ class HOTParamSchema(parameters.Schema): immutable=schema_dict.get(HOTParamSchema.IMMUTABLE, False)) +class HOTParamSchema20170224(HOTParamSchema): + @classmethod + def _constraint_from_def(cls, constraint): + desc = constraint.get(DESCRIPTION) + + if MODULO in constraint: + cdef = constraint.get(MODULO) + cls._check_dict(cdef, MODULO_KEYS, 'modulo constraint') + return constr.Modulo(parameters.Schema.get_num(STEP, cdef), + parameters.Schema.get_num(OFFSET, cdef), + desc) + else: + return super(HOTParamSchema20170224, cls)._constraint_from_def( + constraint) + + class HOTParameters(parameters.Parameters): PSEUDO_PARAMETERS = ( PARAM_STACK_ID, PARAM_STACK_NAME, PARAM_REGION, PARAM_PROJECT_ID diff --git a/heat/engine/hot/template.py b/heat/engine/hot/template.py index bc080fb7c..60c164509 100644 --- a/heat/engine/hot/template.py +++ b/heat/engine/hot/template.py @@ -97,6 +97,8 @@ class HOTemplate20130523(template_common.CommonTemplate): 'Snapshot': rsrc_defn.ResourceDefinition.SNAPSHOT } + param_schema_class = parameters.HOTParamSchema + def __getitem__(self, section): """"Get the relevant section in the template.""" # first translate from CFN into HOT terminology if necessary @@ -211,7 +213,7 @@ class HOTemplate20130523(template_common.CommonTemplate): parameter_section[name]['default'] = pdefaults[name] params = six.iteritems(parameter_section) - return dict((name, parameters.HOTParamSchema.from_dict(name, schema)) + return dict((name, self.param_schema_class.from_dict(name, schema)) for name, schema in params) def parameters(self, stack_identifier, user_params, param_defaults=None): @@ -549,3 +551,5 @@ class HOTemplate20170224(HOTemplate20161014): 'Fn::ResourceFacade': hot_funcs.Removed, 'Ref': hot_funcs.Removed, } + + param_schema_class = parameters.HOTParamSchema20170224 diff --git a/heat/rpc/api.py b/heat/rpc/api.py index c503fff18..403971b73 100644 --- a/heat/rpc/api.py +++ b/heat/rpc/api.py @@ -191,12 +191,13 @@ VALIDATE_PARAM_KEYS = ( PARAM_TYPE, PARAM_DEFAULT, PARAM_NO_ECHO, PARAM_ALLOWED_VALUES, PARAM_ALLOWED_PATTERN, PARAM_MAX_LENGTH, PARAM_MIN_LENGTH, PARAM_MAX_VALUE, PARAM_MIN_VALUE, + PARAM_STEP, PARAM_OFFSET, PARAM_DESCRIPTION, PARAM_CONSTRAINT_DESCRIPTION, PARAM_LABEL, PARAM_CUSTOM_CONSTRAINT, PARAM_VALUE ) = ( 'Type', 'Default', 'NoEcho', 'AllowedValues', 'AllowedPattern', 'MaxLength', - 'MinLength', 'MaxValue', 'MinValue', + 'MinLength', 'MaxValue', 'MinValue', 'Step', 'Offset', 'Description', 'ConstraintDescription', 'Label', 'CustomConstraint', 'Value' ) diff --git a/heat/tests/test_constraints.py b/heat/tests/test_constraints.py index 00280199b..d91a3270d 100644 --- a/heat/tests/test_constraints.py +++ b/heat/tests/test_constraints.py @@ -50,6 +50,12 @@ class SchemaTest(common.HeatTestCase): r = constraints.Length(max=10, description='a length range') self.assertEqual(d, dict(r)) + def test_modulo_schema(self): + d = {'modulo': {'step': 2, 'offset': 1}, + 'description': 'a modulo'} + r = constraints.Modulo(2, 1, description='a modulo') + self.assertEqual(d, dict(r)) + def test_allowed_values_schema(self): d = {'allowed_values': ['foo', 'bar'], 'description': 'allowed values'} r = constraints.AllowedValues(['foo', 'bar'], @@ -86,6 +92,75 @@ class SchemaTest(common.HeatTestCase): l = constraints.Length(max=5, description='a range') self.assertRaises(ValueError, l.validate, 'abcdef') + def test_modulo_validate(self): + r = constraints.Modulo(step=2, offset=1, description='a modulo') + r.validate(1) + r.validate(3) + r.validate(5) + r.validate(777777) + + r = constraints.Modulo(step=111, offset=0, description='a modulo') + r.validate(111) + r.validate(222) + r.validate(444) + r.validate(1110) + + r = constraints.Modulo(step=111, offset=11, description='a modulo') + r.validate(122) + r.validate(233) + r.validate(1121) + + r = constraints.Modulo(step=-2, offset=-1, description='a modulo') + r.validate(-1) + r.validate(-3) + r.validate(-5) + r.validate(-777777) + + r = constraints.Modulo(step=-2, offset=0, description='a modulo') + r.validate(-2) + r.validate(-4) + r.validate(-8888888) + + def test_modulo_validate_fail(self): + r = constraints.Modulo(step=2, offset=1) + err = self.assertRaises(ValueError, r.validate, 4) + self.assertIn('4 is not a multiple of 2 with an offset of 1', + six.text_type(err)) + + self.assertRaises(ValueError, r.validate, 0) + self.assertRaises(ValueError, r.validate, 2) + self.assertRaises(ValueError, r.validate, 888888) + + r = constraints.Modulo(step=2, offset=0) + self.assertRaises(ValueError, r.validate, 1) + self.assertRaises(ValueError, r.validate, 3) + self.assertRaises(ValueError, r.validate, 5) + self.assertRaises(ValueError, r.validate, 777777) + + err = self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, step=111, offset=111) + self.assertIn('offset must be smaller (by absolute value) than step', + six.text_type(err)) + + err = self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, step=111, offset=112) + self.assertIn('offset must be smaller (by absolute value) than step', + six.text_type(err)) + + err = self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, step=0, offset=1) + self.assertIn('step cannot be 0', six.text_type(err)) + + err = self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, step=-2, offset=1) + self.assertIn('step and offset must be both positive or both negative', + six.text_type(err)) + + err = self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, step=2, offset=-1) + self.assertIn('step and offset must be both positive or both negative', + six.text_type(err)) + def test_schema_all(self): d = { 'type': 'string', @@ -206,6 +281,14 @@ class SchemaTest(common.HeatTestCase): self.assertIn('Length constraint invalid for Integer', six.text_type(err)) + def test_modulo_invalid_type(self): + schema = constraints.Schema('String', + constraints=[constraints.Modulo(2, 1)]) + err = self.assertRaises(exception.InvalidSchemaError, + schema.validate) + self.assertIn('Modulo constraint invalid for String', + six.text_type(err)) + def test_allowed_pattern_invalid_type(self): schema = constraints.Schema( 'Integer', @@ -228,6 +311,12 @@ class SchemaTest(common.HeatTestCase): self.assertRaises(exception.InvalidSchemaError, constraints.Length, 1, '10') + def test_modulo_vals_invalid_type(self): + self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, '2', 1) + self.assertRaises(exception.InvalidSchemaError, + constraints.Modulo, 2, '1') + def test_schema_validate_good(self): s = constraints.Schema(constraints.Schema.STRING, 'A string', default='wibble', diff --git a/heat/tests/test_hot.py b/heat/tests/test_hot.py index faa29d304..16aadf72d 100644 --- a/heat/tests/test_hot.py +++ b/heat/tests/test_hot.py @@ -2788,6 +2788,65 @@ class HOTParamValidatorTest(common.HeatTestCase): self.assertEqual( "AllowedPattern must be a string", six.text_type(error)) + def test_modulo_constraint(self): + modulo_desc = 'Value must be an odd number' + modulo_name = 'ControllerCount' + param = { + modulo_name: { + 'description': 'Number of controller nodes', + 'type': 'number', + 'default': 1, + 'constraints': [{ + 'modulo': {'step': 2, 'offset': 1}, + 'description': modulo_desc + }] + } + } + + def v(value): + param_schema = hot_param.HOTParamSchema20170224.from_dict( + modulo_name, param[modulo_name]) + param_schema.validate() + param_schema.validate_value(value) + return True + + value = 2 + err = self.assertRaises(exception.StackValidationFailed, v, value) + self.assertIn(modulo_desc, six.text_type(err)) + + value = 100 + err = self.assertRaises(exception.StackValidationFailed, v, value) + self.assertIn(modulo_desc, six.text_type(err)) + + value = 1 + self.assertTrue(v(value)) + + value = 3 + self.assertTrue(v(value)) + + value = 777 + self.assertTrue(v(value)) + + def test_modulo_constraint_invalid_default(self): + modulo_desc = 'Value must be an odd number' + modulo_name = 'ControllerCount' + param = { + modulo_name: { + 'description': 'Number of controller nodes', + 'type': 'number', + 'default': 2, + 'constraints': [{ + 'modulo': {'step': 2, 'offset': 1}, + 'description': modulo_desc + }] + } + } + + schema = hot_param.HOTParamSchema20170224.from_dict( + modulo_name, param[modulo_name]) + err = self.assertRaises(exception.InvalidSchemaError, schema.validate) + self.assertIn(modulo_desc, six.text_type(err)) + class TestGetAttAllAttributes(common.HeatTestCase): scenarios = [