Merge "Add a modulo core constraint"

This commit is contained in:
Jenkins 2016-10-26 04:12:29 +00:00 committed by Gerrit Code Review
commit 708bda5152
9 changed files with 315 additions and 30 deletions

View File

@ -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)

View File

@ -532,6 +532,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: <step>, offset: <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
++++++++++++++

View File

@ -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)

View File

@ -443,6 +443,85 @@ class Length(Range):
template)
class Modulo(Constraint):
"""Constrain values to modulo.
Serializes to JSON as::
{
'modulo': {'step': <step>, 'offset': <offset>},
'description': <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.

View File

@ -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

View File

@ -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):
@ -552,3 +554,5 @@ class HOTemplate20170224(HOTemplate20161014):
'Fn::ResourceFacade': hot_funcs.Removed,
'Ref': hot_funcs.Removed,
}
param_schema_class = parameters.HOTParamSchema20170224

View File

@ -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'
)

View File

@ -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',

View File

@ -2872,6 +2872,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 = [