Merge "Add a modulo core constraint"
This commit is contained in:
commit
708bda5152
@ -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)
|
||||
|
@ -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
|
||||
++++++++++++++
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
)
|
||||
|
@ -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',
|
||||
|
@ -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 = [
|
||||
|
Loading…
x
Reference in New Issue
Block a user