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:
Thomas Spatzier 2014-05-21 16:36:59 +02:00
parent dae580f84d
commit 43c33fff9b
3 changed files with 295 additions and 9 deletions

View File

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

View File

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

View File

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