Respect schema data type in constraint validation
This patch makes Constraint objects aware of the data type of the owning Schema object so constraint validation can apply potential type casts based on the data type. This is especially useful and necessary for an AllowedValues constraint and numeric values, where items may be defined as strings or numbers and cause comparison issues with user values at runtime. Change-Id: Id1884b4968993fc9356e7ba4ad03dced4cd00d4e Closes-Bug: #1321540
This commit is contained in:
parent
dae580f84d
commit
43c33fff9b
|
@ -20,6 +20,7 @@ import six
|
|||
from heat.common import exception
|
||||
from heat.engine import clients
|
||||
from heat.engine import resources
|
||||
from heat.openstack.common import strutils
|
||||
|
||||
|
||||
class InvalidSchemaError(exception.Error):
|
||||
|
@ -161,10 +162,37 @@ class Schema(collections.Mapping):
|
|||
except ValueError:
|
||||
return float(value)
|
||||
|
||||
def to_schema_type(self, value):
|
||||
"""Returns the value in the schema's data type."""
|
||||
try:
|
||||
# We have to be backwards-compatible for Integer and Number
|
||||
# Schema types and try to convert string representations of
|
||||
# number into "real" number types, therefore calling
|
||||
# str_to_num below.
|
||||
if self.type == self.INTEGER:
|
||||
num = Schema.str_to_num(value)
|
||||
if isinstance(num, float):
|
||||
raise ValueError(_('%s is not an integer.') % num)
|
||||
return num
|
||||
elif self.type == self.NUMBER:
|
||||
return Schema.str_to_num(value)
|
||||
elif self.type == self.STRING:
|
||||
if value and not isinstance(value, basestring):
|
||||
raise ValueError()
|
||||
return str(value)
|
||||
elif self.type == self.BOOLEAN:
|
||||
return strutils.bool_from_string(str(value), strict=True)
|
||||
except ValueError:
|
||||
raise ValueError(_('Value "%(val)s" is invalid for data type '
|
||||
'"%(type)s".')
|
||||
% {'val': value, 'type': self.type})
|
||||
|
||||
return value
|
||||
|
||||
def validate_constraints(self, value, context=None):
|
||||
try:
|
||||
for constraint in self.constraints:
|
||||
constraint.validate(value, context)
|
||||
constraint.validate(value, self, context)
|
||||
except ValueError as ex:
|
||||
raise exception.StackValidationFailed(message=six.text_type(ex))
|
||||
|
||||
|
@ -250,8 +278,8 @@ class Constraint(collections.Mapping):
|
|||
|
||||
return '\n'.join(desc())
|
||||
|
||||
def validate(self, value, context=None):
|
||||
if not self._is_valid(value, context):
|
||||
def validate(self, value, schema=None, context=None):
|
||||
if not self._is_valid(value, schema, context):
|
||||
if self.description:
|
||||
err_msg = self.description
|
||||
else:
|
||||
|
@ -328,7 +356,7 @@ class Range(Constraint):
|
|||
self.min,
|
||||
self.max)
|
||||
|
||||
def _is_valid(self, value, context):
|
||||
def _is_valid(self, value, schema, context):
|
||||
value = Schema.str_to_num(value)
|
||||
|
||||
if self.min is not None:
|
||||
|
@ -392,8 +420,8 @@ class Length(Range):
|
|||
self.min,
|
||||
self.max)
|
||||
|
||||
def _is_valid(self, value, context):
|
||||
return super(Length, self)._is_valid(len(value), context)
|
||||
def _is_valid(self, value, schema, context):
|
||||
return super(Length, self)._is_valid(len(value), schema, context)
|
||||
|
||||
|
||||
class AllowedValues(Constraint):
|
||||
|
@ -426,12 +454,16 @@ class AllowedValues(Constraint):
|
|||
allowed = '[%s]' % ', '.join(str(a) for a in self.allowed)
|
||||
return '"%s" is not an allowed value %s' % (value, allowed)
|
||||
|
||||
def _is_valid(self, value, context):
|
||||
def _is_valid(self, value, schema, context):
|
||||
# For list values, check if all elements of the list are contained
|
||||
# in allowed list.
|
||||
if isinstance(value, list):
|
||||
return all(v in self.allowed for v in value)
|
||||
|
||||
if schema is not None:
|
||||
_allowed = tuple(schema.to_schema_type(v) for v in self.allowed)
|
||||
return schema.to_schema_type(value) in _allowed
|
||||
|
||||
return value in self.allowed
|
||||
|
||||
def _constraint(self):
|
||||
|
@ -465,7 +497,7 @@ class AllowedPattern(Constraint):
|
|||
def _err_msg(self, value):
|
||||
return '"%s" does not match pattern "%s"' % (value, self.pattern)
|
||||
|
||||
def _is_valid(self, value, context):
|
||||
def _is_valid(self, value, schema, context):
|
||||
match = self.match(value)
|
||||
return match is not None and match.end() == len(value)
|
||||
|
||||
|
@ -518,7 +550,7 @@ class CustomConstraint(Constraint):
|
|||
return _('"%(value)s" does not validate %(name)s') % {
|
||||
"value": value, "name": self.name}
|
||||
|
||||
def _is_valid(self, value, context):
|
||||
def _is_valid(self, value, schema, context):
|
||||
constraint = self.custom_constraint
|
||||
if not constraint:
|
||||
return False
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import testtools
|
||||
|
||||
from heat.common import exception
|
||||
from heat.engine import constraints
|
||||
from heat.engine import environment
|
||||
|
||||
|
@ -255,6 +256,169 @@ class SchemaTest(testtools.TestCase):
|
|||
err = self.assertRaises(constraints.InvalidSchemaError, s.validate)
|
||||
self.assertIn('Range constraint invalid for String', str(err))
|
||||
|
||||
def test_allowed_values_numeric_int(self):
|
||||
'''
|
||||
Test AllowedValues constraint for numeric integer values.
|
||||
|
||||
Test if the AllowedValues constraint works for numeric values in any
|
||||
combination of numeric strings or numbers in the constraint and
|
||||
numeric strings or numbers as value.
|
||||
'''
|
||||
|
||||
# Allowed values defined as integer numbers
|
||||
schema = constraints.Schema(
|
||||
'Integer',
|
||||
constraints=[constraints.AllowedValues([1, 2, 4])]
|
||||
)
|
||||
# ... and value as number or string
|
||||
self.assertIsNone(schema.validate_constraints(1))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, 3)
|
||||
self.assertEqual('"3" is not an allowed value [1, 2, 4]', str(err))
|
||||
self.assertIsNone(schema.validate_constraints('1'))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, '3')
|
||||
self.assertEqual('"3" is not an allowed value [1, 2, 4]', str(err))
|
||||
|
||||
# Allowed values defined as integer strings
|
||||
schema = constraints.Schema(
|
||||
'Integer',
|
||||
constraints=[constraints.AllowedValues(['1', '2', '4'])]
|
||||
)
|
||||
# ... and value as number or string
|
||||
self.assertIsNone(schema.validate_constraints(1))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, 3)
|
||||
self.assertEqual('"3" is not an allowed value [1, 2, 4]', str(err))
|
||||
self.assertIsNone(schema.validate_constraints('1'))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, '3')
|
||||
self.assertEqual('"3" is not an allowed value [1, 2, 4]', str(err))
|
||||
|
||||
def test_allowed_values_numeric_float(self):
|
||||
'''
|
||||
Test AllowedValues constraint for numeric floating point values.
|
||||
|
||||
Test if the AllowedValues constraint works for numeric values in any
|
||||
combination of numeric strings or numbers in the constraint and
|
||||
numeric strings or numbers as value.
|
||||
'''
|
||||
|
||||
# Allowed values defined as numbers
|
||||
schema = constraints.Schema(
|
||||
'Number',
|
||||
constraints=[constraints.AllowedValues([1.1, 2.2, 4.4])]
|
||||
)
|
||||
# ... and value as number or string
|
||||
self.assertIsNone(schema.validate_constraints(1.1))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, 3.3)
|
||||
self.assertEqual('"3.3" is not an allowed value [1.1, 2.2, 4.4]',
|
||||
str(err))
|
||||
self.assertIsNone(schema.validate_constraints('1.1'))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, '3.3')
|
||||
self.assertEqual('"3.3" is not an allowed value [1.1, 2.2, 4.4]',
|
||||
str(err))
|
||||
|
||||
# Allowed values defined as strings
|
||||
schema = constraints.Schema(
|
||||
'Number',
|
||||
constraints=[constraints.AllowedValues(['1.1', '2.2', '4.4'])]
|
||||
)
|
||||
# ... and value as number or string
|
||||
self.assertIsNone(schema.validate_constraints(1.1))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, 3.3)
|
||||
self.assertEqual('"3.3" is not an allowed value [1.1, 2.2, 4.4]',
|
||||
str(err))
|
||||
self.assertIsNone(schema.validate_constraints('1.1'))
|
||||
err = self.assertRaises(exception.StackValidationFailed,
|
||||
schema.validate_constraints, '3.3')
|
||||
self.assertEqual('"3.3" is not an allowed value [1.1, 2.2, 4.4]',
|
||||
str(err))
|
||||
|
||||
def test_to_schema_type_int(self):
|
||||
'''Test Schema.to_schema_type method for type Integer.'''
|
||||
schema = constraints.Schema('Integer')
|
||||
# test valid values, i.e. integeres as string or number
|
||||
res = schema.to_schema_type(1)
|
||||
self.assertIsInstance(res, int)
|
||||
res = schema.to_schema_type('1')
|
||||
self.assertIsInstance(res, int)
|
||||
# test invalid numeric values, i.e. floating point numbers
|
||||
err = self.assertRaises(ValueError, schema.to_schema_type, 1.5)
|
||||
self.assertEqual('Value "1.5" is invalid for data type "Integer".',
|
||||
str(err))
|
||||
err = self.assertRaises(ValueError, schema.to_schema_type, '1.5')
|
||||
self.assertEqual('Value "1.5" is invalid for data type "Integer".',
|
||||
str(err))
|
||||
# test invalid string values
|
||||
err = self.assertRaises(ValueError, schema.to_schema_type, 'foo')
|
||||
self.assertEqual('Value "foo" is invalid for data type "Integer".',
|
||||
str(err))
|
||||
|
||||
def test_to_schema_type_num(self):
|
||||
'''Test Schema.to_schema_type method for type Number.'''
|
||||
schema = constraints.Schema('Number')
|
||||
res = schema.to_schema_type(1)
|
||||
self.assertIsInstance(res, int)
|
||||
res = schema.to_schema_type('1')
|
||||
self.assertIsInstance(res, int)
|
||||
res = schema.to_schema_type(1.5)
|
||||
self.assertIsInstance(res, float)
|
||||
res = schema.to_schema_type('1.5')
|
||||
self.assertIsInstance(res, float)
|
||||
self.assertEqual(1.5, res)
|
||||
err = self.assertRaises(ValueError, schema.to_schema_type, 'foo')
|
||||
self.assertEqual('Value "foo" is invalid for data type "Number".',
|
||||
str(err))
|
||||
|
||||
def test_to_schema_type_string(self):
|
||||
'''Test Schema.to_schema_type method for type String.'''
|
||||
schema = constraints.Schema('String')
|
||||
res = schema.to_schema_type('one')
|
||||
self.assertIsInstance(res, basestring)
|
||||
res = schema.to_schema_type('1')
|
||||
self.assertIsInstance(res, basestring)
|
||||
err = self.assertRaises(ValueError, schema.to_schema_type, 1)
|
||||
self.assertEqual('Value "1" is invalid for data type "String".',
|
||||
str(err))
|
||||
|
||||
def test_to_schema_type_boolean(self):
|
||||
'''Test Schema.to_schema_type method for type Boolean.'''
|
||||
schema = constraints.Schema('Boolean')
|
||||
|
||||
true_values = [1, '1', True, 'true', 'True', 'yes', 'Yes']
|
||||
for v in true_values:
|
||||
res = schema.to_schema_type(v)
|
||||
self.assertIsInstance(res, bool)
|
||||
self.assertTrue(res)
|
||||
|
||||
false_values = [0, '0', False, 'false', 'False', 'No', 'no']
|
||||
for v in false_values:
|
||||
res = schema.to_schema_type(v)
|
||||
self.assertIsInstance(res, bool)
|
||||
self.assertFalse(res)
|
||||
|
||||
err = self.assertRaises(ValueError, schema.to_schema_type, 'foo')
|
||||
self.assertEqual('Value "foo" is invalid for data type "Boolean".',
|
||||
str(err))
|
||||
|
||||
def test_to_schema_type_map(self):
|
||||
'''Test Schema.to_schema_type method for type Map.'''
|
||||
schema = constraints.Schema('Map')
|
||||
res = schema.to_schema_type({'a': 'aa', 'b': 'bb'})
|
||||
self.assertIsInstance(res, dict)
|
||||
self.assertEqual({'a': 'aa', 'b': 'bb'}, res)
|
||||
|
||||
def test_to_schema_type_list(self):
|
||||
'''Test Schema.to_schema_type method for type List.'''
|
||||
schema = constraints.Schema('List')
|
||||
res = schema.to_schema_type(['a', 'b'])
|
||||
self.assertIsInstance(res, list)
|
||||
self.assertEqual(['a', 'b'], res)
|
||||
|
||||
|
||||
class CustomConstraintTest(testtools.TestCase):
|
||||
|
||||
|
|
|
@ -773,6 +773,36 @@ resources:
|
|||
type: OS::Nova::Server
|
||||
'''
|
||||
|
||||
test_template_allowed_integers = '''
|
||||
heat_template_version: 2013-05-23
|
||||
|
||||
parameters:
|
||||
size:
|
||||
type: number
|
||||
constraints:
|
||||
- allowed_values: [1, 4, 8]
|
||||
resources:
|
||||
my_volume:
|
||||
type: OS::Cinder::Volume
|
||||
properties:
|
||||
size: { get_param: size }
|
||||
'''
|
||||
|
||||
test_template_allowed_integers_str = '''
|
||||
heat_template_version: 2013-05-23
|
||||
|
||||
parameters:
|
||||
size:
|
||||
type: number
|
||||
constraints:
|
||||
- allowed_values: ['1', '4', '8']
|
||||
resources:
|
||||
my_volume:
|
||||
type: OS::Cinder::Volume
|
||||
properties:
|
||||
size: { get_param: size }
|
||||
'''
|
||||
|
||||
|
||||
class validateTest(HeatTestCase):
|
||||
def setUp(self):
|
||||
|
@ -1322,3 +1352,63 @@ class validateTest(HeatTestCase):
|
|||
|
||||
self.assertEqual(_('Parameters must be provided for each Parameter '
|
||||
'Group.'), str(exc))
|
||||
|
||||
def test_validate_allowed_values_integer(self):
|
||||
t = template_format.parse(test_template_allowed_integers)
|
||||
template = parser.Template(t)
|
||||
|
||||
# test with size parameter provided as string
|
||||
stack = parser.Stack(self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': '4'}))
|
||||
self.assertIsNone(stack.validate())
|
||||
|
||||
# test with size parameter provided as number
|
||||
stack = parser.Stack(self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': 4}))
|
||||
self.assertIsNone(stack.validate())
|
||||
|
||||
def test_validate_allowed_values_integer_str(self):
|
||||
t = template_format.parse(test_template_allowed_integers_str)
|
||||
template = parser.Template(t)
|
||||
|
||||
# test with size parameter provided as string
|
||||
stack = parser.Stack(self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': '4'}))
|
||||
self.assertIsNone(stack.validate())
|
||||
|
||||
# test with size parameter provided as number
|
||||
stack = parser.Stack(self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': 4}))
|
||||
self.assertIsNone(stack.validate())
|
||||
|
||||
def test_validate_not_allowed_values_integer(self):
|
||||
t = template_format.parse(test_template_allowed_integers)
|
||||
template = parser.Template(t)
|
||||
|
||||
# test with size parameter provided as string
|
||||
err = self.assertRaises(exception.StackValidationFailed, parser.Stack,
|
||||
self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': '3'}))
|
||||
self.assertIn('"3" is not an allowed value [1, 4, 8]', str(err))
|
||||
|
||||
# test with size parameter provided as number
|
||||
err = self.assertRaises(exception.StackValidationFailed, parser.Stack,
|
||||
self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': 3}))
|
||||
self.assertIn('"3" is not an allowed value [1, 4, 8]', str(err))
|
||||
|
||||
def test_validate_not_allowed_values_integer_str(self):
|
||||
t = template_format.parse(test_template_allowed_integers_str)
|
||||
template = parser.Template(t)
|
||||
|
||||
# test with size parameter provided as string
|
||||
err = self.assertRaises(exception.StackValidationFailed, parser.Stack,
|
||||
self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': '3'}))
|
||||
self.assertIn('"3" is not an allowed value [1, 4, 8]', str(err))
|
||||
|
||||
# test with size parameter provided as number
|
||||
err = self.assertRaises(exception.StackValidationFailed, parser.Stack,
|
||||
self.ctx, 'test_stack', template,
|
||||
environment.Environment({'size': 3}))
|
||||
self.assertIn('"3" is not an allowed value [1, 4, 8]', str(err))
|
||||
|
|
Loading…
Reference in New Issue