Fix HOT inconsistencies in resource sections
This patch fixes inconsistencies between keywords in resource definitions in a HOT template as documented in the HOT specification and the way they are processed by the code. So far it was possible to use CFN like syntax or HOT syntax inside resource definitions (e.g. both 'properties' and 'Properties' were accepted) since the HOT processing code did tolerant processing. Consequently, users had been able to produce CFN/HOT mixed templates. This patch introduces strict enforcement of allowed keywords to make HOT templates consistent with the spec by providing concrete validation errors in case of invalid keywords. This patch also adds a DB migration step to update existing templates in the Heat database with potentially legacy keywords to comply with the new strict syntax checking rules. Change-Id: I44309acda86a930030cb70493f409e3ea4740130 Closes-Bug: #1274288 Closes-Bug: #1271008
This commit is contained in:
parent
22fd357a5e
commit
18bd016388
|
@ -398,7 +398,11 @@ to the syntax below.
|
|||
type: <resource type>
|
||||
properties:
|
||||
<property name>: <property value>
|
||||
# more resource specific metadata
|
||||
metadata:
|
||||
<resource specific metadata>
|
||||
depends_on: <resource ID or list of ID>
|
||||
update_poliy: <update policy>
|
||||
deletion_policy: <deletion policy>
|
||||
|
||||
resource ID
|
||||
A resource block is headed by the resource ID, which must be unique within
|
||||
|
@ -406,12 +410,28 @@ resource ID
|
|||
type
|
||||
This attribute specifies the type of resource, such as OS::Nova::Server.
|
||||
properties
|
||||
This section contains a list of resource specific properties. The property
|
||||
value can be provided in place, or can be provided via a function
|
||||
(see :ref:`hot_spec_intrinsic_functions`).
|
||||
This *optional* section contains a list of resource specific properties.
|
||||
The property value can be provided in place, or can be provided via a
|
||||
function (see :ref:`hot_spec_intrinsic_functions`).
|
||||
metadata
|
||||
This *optional* section contains resource type specific metadata.
|
||||
depends_on
|
||||
This *optional* attribute allows for specifying dependencies of the current
|
||||
resource on one or more other resources. Please refer to section
|
||||
:ref:`hot_spec_resources_dependencies` for details.
|
||||
update_policy:
|
||||
This *optional* attribute allows for specifying an update policy for the
|
||||
resource in the form of a nested dictionary (name-value pairs). Whether
|
||||
update policies are supported and what the exact semantics are depends on
|
||||
the type of the current resource.
|
||||
deletion_policy:
|
||||
This *optional* attribute allows for specifying a deletion policy for the
|
||||
resource (one of the values Delete, Retain or Snapshot). Which type of
|
||||
deletion policy is supported depends on the type of the current resource.
|
||||
|
||||
|
||||
Depending on the type of resource, the resource block might include more
|
||||
resource specific metadata. Basically all resource types that can be used in
|
||||
resource specific data. Basically all resource types that can be used in
|
||||
CFN templates can also be used in HOT templates, adapted to the YAML structure
|
||||
as outlined above.
|
||||
Below is an example of a simple compute resource definition with some fixed
|
||||
|
@ -427,6 +447,45 @@ property values.
|
|||
image: F18-x86_64-cfntools
|
||||
|
||||
|
||||
.. _hot_spec_resources_dependencies:
|
||||
|
||||
Resource Dependencies
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
By means of the *depends_on* attribute within a resource section it is possible
|
||||
to define a dependency between a resource and one or more other resources. If
|
||||
a resource depends on just one other resource, the ID of the other resource is
|
||||
specified as value of the *depends_on* attribute as shown in the following
|
||||
example.
|
||||
|
||||
::
|
||||
|
||||
resources:
|
||||
server1:
|
||||
type: OS::Nova::Server
|
||||
depends_on: server2
|
||||
|
||||
server2:
|
||||
type: OS::Nova::Server
|
||||
|
||||
If a resource depends on more than one other resource, the value of the
|
||||
*depends_on* attribute is specified as a list of resource IDs as shown in the
|
||||
following example:
|
||||
|
||||
::
|
||||
|
||||
resources:
|
||||
server1:
|
||||
type: OS::Nova::Server
|
||||
depends_on: [ server2, server3 ]
|
||||
|
||||
server2:
|
||||
type: OS::Nova::Server
|
||||
|
||||
server3:
|
||||
type: OS::Nova::Server
|
||||
|
||||
|
||||
.. _hot_spec_outputs:
|
||||
|
||||
---------------
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
from migrate.versioning import util as migrate_util
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from heat.openstack.common.gettextutils import _
|
||||
from heat.db.sqlalchemy import models
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
Session = sessionmaker(bind=migrate_engine)
|
||||
session = Session()
|
||||
|
||||
raw_templates = session.query(models.RawTemplate).all()
|
||||
|
||||
CFN_TO_HOT_RESOURCE_ATTRS = {'Type': 'type',
|
||||
'Properties': 'properties',
|
||||
'Metadata': 'metadata',
|
||||
'DependsOn': 'depends_on',
|
||||
'DeletionPolicy': 'deletion_policy',
|
||||
'UpdatePolicy': 'update_policy'}
|
||||
|
||||
CFN_TO_HOT_OUTPUT_ATTRS = {'Description': 'description',
|
||||
'Value': 'value'}
|
||||
|
||||
def _translate(section, translate_map):
|
||||
changed = False
|
||||
|
||||
for name, details in section.iteritems():
|
||||
for old_key, new_key in translate_map.iteritems():
|
||||
if old_key in details:
|
||||
details[new_key] = details[old_key]
|
||||
del details[old_key]
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
|
||||
for raw_template in raw_templates:
|
||||
if 'heat_template_version' in raw_template.template:
|
||||
|
||||
changed = False
|
||||
template = copy.deepcopy(raw_template.template)
|
||||
|
||||
resources = template.get('resources', {})
|
||||
if _translate(resources, CFN_TO_HOT_RESOURCE_ATTRS):
|
||||
changed = True
|
||||
|
||||
outputs = template.get('outputs', {})
|
||||
if _translate(outputs, CFN_TO_HOT_OUTPUT_ATTRS):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
raw_template.template = template
|
||||
session.commit()
|
||||
|
||||
|
||||
def downgrade(migrate_engine):
|
||||
migrate_util.log.warning(_('This version cannot be downgraded because '
|
||||
'it involves a data migration to the '
|
||||
'raw_template table.'))
|
|
@ -44,8 +44,10 @@ class HOTemplate(template.Template):
|
|||
def __getitem__(self, section):
|
||||
""""Get the relevant section in the template."""
|
||||
#first translate from CFN into HOT terminology if necessary
|
||||
section = HOTemplate._translate(section,
|
||||
self._CFN_TO_HOT_SECTIONS, section)
|
||||
if section not in self.SECTIONS:
|
||||
section = HOTemplate._translate(section, self._CFN_TO_HOT_SECTIONS,
|
||||
_('"%s" is not a valid template '
|
||||
'section'))
|
||||
|
||||
if section not in self.SECTIONS:
|
||||
raise KeyError(_('"%s" is not a valid template section') % section)
|
||||
|
@ -77,16 +79,23 @@ class HOTemplate(template.Template):
|
|||
return the_section
|
||||
|
||||
@staticmethod
|
||||
def _translate(value, mapping, default=None):
|
||||
if value in mapping:
|
||||
def _translate(value, mapping, err_msg=None):
|
||||
try:
|
||||
return mapping[value]
|
||||
|
||||
return default
|
||||
except KeyError as ke:
|
||||
if err_msg:
|
||||
raise KeyError(err_msg % value)
|
||||
else:
|
||||
raise ke
|
||||
|
||||
def _translate_resources(self, resources):
|
||||
"""Get the resources of the template translated into CFN format."""
|
||||
HOT_TO_CFN_ATTRS = {'type': 'Type',
|
||||
'properties': 'Properties'}
|
||||
'properties': 'Properties',
|
||||
'metadata': 'Metadata',
|
||||
'depends_on': 'DependsOn',
|
||||
'deletion_policy': 'DeletionPolicy',
|
||||
'update_policy': 'UpdatePolicy'}
|
||||
|
||||
cfn_resources = {}
|
||||
|
||||
|
@ -94,7 +103,9 @@ class HOTemplate(template.Template):
|
|||
cfn_resource = {}
|
||||
|
||||
for attr, attr_value in attrs.iteritems():
|
||||
cfn_attr = self._translate(attr, HOT_TO_CFN_ATTRS, attr)
|
||||
cfn_attr = self._translate(attr, HOT_TO_CFN_ATTRS,
|
||||
_('"%s" is not a valid keyword '
|
||||
'inside a resource definition'))
|
||||
cfn_resource[cfn_attr] = attr_value
|
||||
|
||||
cfn_resources[resource_name] = cfn_resource
|
||||
|
@ -112,7 +123,9 @@ class HOTemplate(template.Template):
|
|||
cfn_output = {}
|
||||
|
||||
for attr, attr_value in attrs.iteritems():
|
||||
cfn_attr = self._translate(attr, HOT_TO_CFN_ATTRS, attr)
|
||||
cfn_attr = self._translate(attr, HOT_TO_CFN_ATTRS,
|
||||
_('"%s" is not a valid keyword '
|
||||
'inside an output definition'))
|
||||
cfn_output[cfn_attr] = attr_value
|
||||
|
||||
cfn_outputs[output_name] = cfn_output
|
||||
|
|
|
@ -292,6 +292,11 @@ class InstanceGroup(stack_resource.StackResource):
|
|||
# resolve references within the context of this stack.
|
||||
return self.stack.resolve_runtime_data(instance_definition)
|
||||
|
||||
def _get_instance_templates(self):
|
||||
"""Get templates for resource instances."""
|
||||
return [(instance.name, instance.t)
|
||||
for instance in self.get_instances()]
|
||||
|
||||
def _create_template(self, num_instances, num_replace=0):
|
||||
"""
|
||||
Create a template to represent autoscaled instances.
|
||||
|
@ -299,8 +304,7 @@ class InstanceGroup(stack_resource.StackResource):
|
|||
Also see heat.scaling.template.resource_templates.
|
||||
"""
|
||||
instance_definition = self._get_instance_definition()
|
||||
old_resources = [(instance.name, instance.t)
|
||||
for instance in self.get_instances()]
|
||||
old_resources = self._get_instance_templates()
|
||||
templates = template.resource_templates(
|
||||
old_resources, instance_definition, num_instances, num_replace)
|
||||
return {"Resources": dict(templates)}
|
||||
|
@ -870,6 +874,34 @@ class AutoScalingResourceGroup(AutoScalingGroup):
|
|||
"""
|
||||
pass
|
||||
|
||||
def _get_instance_templates(self):
|
||||
"""
|
||||
Get templates for resource instances in HOT format.
|
||||
|
||||
AutoScalingResourceGroup uses HOT as template format for scaled
|
||||
resources. Templates for existing resources use CFN syntax and
|
||||
have to be converted to HOT since those templates get used for
|
||||
generating scaled resource templates.
|
||||
"""
|
||||
CFN_TO_HOT_ATTRS = {'Type': 'type',
|
||||
'Properties': 'properties',
|
||||
'Metadata': 'metadata',
|
||||
'DependsOn': 'depends_on',
|
||||
'DeletionPolicy': 'deletion_policy',
|
||||
'UpdatePolicy': 'update_policy'}
|
||||
|
||||
def to_hot(template):
|
||||
hot_template = {}
|
||||
|
||||
for attr, attr_value in template.iteritems():
|
||||
hot_attr = CFN_TO_HOT_ATTRS.get(attr, attr)
|
||||
hot_template[hot_attr] = attr_value
|
||||
|
||||
return hot_template
|
||||
|
||||
return [(instance.name, to_hot(instance.t))
|
||||
for instance in self.get_instances()]
|
||||
|
||||
def _create_template(self, *args, **kwargs):
|
||||
"""Use a HOT format for the template in the nested stack."""
|
||||
tpl = super(AutoScalingResourceGroup, self)._create_template(
|
||||
|
|
|
@ -83,7 +83,7 @@ class HOTemplateTest(HeatTestCase):
|
|||
self.assertEqual({}, tmpl[tmpl.RESOURCES])
|
||||
self.assertEqual({}, tmpl[tmpl.OUTPUTS])
|
||||
|
||||
def test_translate_resources(self):
|
||||
def test_translate_resources_good(self):
|
||||
"""Test translation of resources into internal engine format."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
|
@ -93,15 +93,157 @@ class HOTemplateTest(HeatTestCase):
|
|||
type: AWS::EC2::Instance
|
||||
properties:
|
||||
property1: value1
|
||||
metadata:
|
||||
foo: bar
|
||||
depends_on: dummy
|
||||
deletion_policy: dummy
|
||||
update_policy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
expected = {'resource1': {'Type': 'AWS::EC2::Instance',
|
||||
'Properties': {'property1': 'value1'}}}
|
||||
'Properties': {'property1': 'value1'},
|
||||
'Metadata': {'foo': 'bar'},
|
||||
'DependsOn': 'dummy',
|
||||
'DeletionPolicy': 'dummy',
|
||||
'UpdatePolicy': {'foo': 'bar'}}}
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
self.assertEqual(expected, tmpl[tmpl.RESOURCES])
|
||||
|
||||
def test_translate_outputs(self):
|
||||
def test_translate_resources_bad_type(self):
|
||||
"""Test translation of resources including invalid keyword."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
resources:
|
||||
resource1:
|
||||
Type: AWS::EC2::Instance
|
||||
properties:
|
||||
property1: value1
|
||||
metadata:
|
||||
foo: bar
|
||||
depends_on: dummy
|
||||
deletion_policy: dummy
|
||||
update_policy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.RESOURCES)
|
||||
self.assertIn('Type', str(err))
|
||||
|
||||
def test_translate_resources_bad_properties(self):
|
||||
"""Test translation of resources including invalid keyword."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
resources:
|
||||
resource1:
|
||||
type: AWS::EC2::Instance
|
||||
Properties:
|
||||
property1: value1
|
||||
metadata:
|
||||
foo: bar
|
||||
depends_on: dummy
|
||||
deletion_policy: dummy
|
||||
update_policy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.RESOURCES)
|
||||
self.assertIn('Properties', str(err))
|
||||
|
||||
def test_translate_resources_bad_metadata(self):
|
||||
"""Test translation of resources including invalid keyword."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
resources:
|
||||
resource1:
|
||||
type: AWS::EC2::Instance
|
||||
properties:
|
||||
property1: value1
|
||||
Metadata:
|
||||
foo: bar
|
||||
depends_on: dummy
|
||||
deletion_policy: dummy
|
||||
update_policy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.RESOURCES)
|
||||
self.assertIn('Metadata', str(err))
|
||||
|
||||
def test_translate_resources_bad_depends_on(self):
|
||||
"""Test translation of resources including invalid keyword."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
resources:
|
||||
resource1:
|
||||
type: AWS::EC2::Instance
|
||||
properties:
|
||||
property1: value1
|
||||
metadata:
|
||||
foo: bar
|
||||
DependsOn: dummy
|
||||
deletion_policy: dummy
|
||||
update_policy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.RESOURCES)
|
||||
self.assertIn('DependsOn', str(err))
|
||||
|
||||
def test_translate_resources_bad_deletion_polciy(self):
|
||||
"""Test translation of resources including invalid keyword."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
resources:
|
||||
resource1:
|
||||
type: AWS::EC2::Instance
|
||||
properties:
|
||||
property1: value1
|
||||
metadata:
|
||||
foo: bar
|
||||
depends_on: dummy
|
||||
DeletionPolicy: dummy
|
||||
update_policy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.RESOURCES)
|
||||
self.assertIn('DeletionPolicy', str(err))
|
||||
|
||||
def test_translate_resources_bad_update_policy(self):
|
||||
"""Test translation of resources including invalid keyword."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
resources:
|
||||
resource1:
|
||||
type: AWS::EC2::Instance
|
||||
properties:
|
||||
property1: value1
|
||||
metadata:
|
||||
foo: bar
|
||||
depends_on: dummy
|
||||
deletion_policy: dummy
|
||||
UpdatePolicy:
|
||||
foo: bar
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.RESOURCES)
|
||||
self.assertIn('UpdatePolicy', str(err))
|
||||
|
||||
def test_translate_outputs_good(self):
|
||||
"""Test translation of outputs into internal engine format."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
|
@ -117,6 +259,36 @@ class HOTemplateTest(HeatTestCase):
|
|||
tmpl = parser.Template(hot_tpl)
|
||||
self.assertEqual(expected, tmpl[tmpl.OUTPUTS])
|
||||
|
||||
def test_translate_outputs_bad_description(self):
|
||||
"""Test translation of outputs into internal engine format."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
outputs:
|
||||
output1:
|
||||
Description: output1
|
||||
value: value1
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.OUTPUTS)
|
||||
self.assertIn('Description', str(err))
|
||||
|
||||
def test_translate_outputs_bad_value(self):
|
||||
"""Test translation of outputs into internal engine format."""
|
||||
|
||||
hot_tpl = template_format.parse('''
|
||||
heat_template_version: 2013-05-23
|
||||
outputs:
|
||||
output1:
|
||||
description: output1
|
||||
Value: value1
|
||||
''')
|
||||
|
||||
tmpl = parser.Template(hot_tpl)
|
||||
err = self.assertRaises(KeyError, tmpl.__getitem__, tmpl.OUTPUTS)
|
||||
self.assertIn('Value', str(err))
|
||||
|
||||
def test_str_replace(self):
|
||||
"""Test str_replace function."""
|
||||
|
||||
|
@ -433,7 +605,7 @@ class StackTest(test_parser.StackTest):
|
|||
tmpl = template.Template(
|
||||
{'heat_template_version': '2013-05-23',
|
||||
'resources': {'AResource': {'type': 'ResourceWithPropsType',
|
||||
'Metadata': {'Bar': {'get_param': 'OS::stack_id'}},
|
||||
'metadata': {'Bar': {'get_param': 'OS::stack_id'}},
|
||||
'properties': {'Foo': 'abc'}}}})
|
||||
self.stack = parser.Stack(self.ctx, 'update_stack_id_test', tmpl)
|
||||
self.stack.store()
|
||||
|
@ -446,7 +618,7 @@ class StackTest(test_parser.StackTest):
|
|||
tmpl2 = template.Template(
|
||||
{'heat_template_version': '2013-05-23',
|
||||
'resources': {'AResource': {'type': 'ResourceWithPropsType',
|
||||
'Metadata': {'Bar': {'get_param': 'OS::stack_id'}},
|
||||
'metadata': {'Bar': {'get_param': 'OS::stack_id'}},
|
||||
'properties': {'Foo': 'xyz'}}}})
|
||||
updated_stack = parser.Stack(self.ctx, 'updated_stack', tmpl2)
|
||||
|
||||
|
|
|
@ -999,7 +999,7 @@ class ResourceDependenciesTest(HeatTestCase):
|
|||
'foo': {'type': 'GenericResourceType'},
|
||||
'bar': {
|
||||
'type': 'ResourceWithPropsType',
|
||||
'Properties': {
|
||||
'properties': {
|
||||
'Foo': {'get_attr': ['foo', 'bar']},
|
||||
}
|
||||
}
|
||||
|
@ -1205,6 +1205,26 @@ class ResourceDependenciesTest(HeatTestCase):
|
|||
self.assertIn(res, graph)
|
||||
self.assertIn(stack['foo'], graph[res])
|
||||
|
||||
def test_dependson_hot(self):
|
||||
tmpl = template.Template({
|
||||
'heat_template_version': '2013-05-23',
|
||||
'resources': {
|
||||
'foo': {'type': 'GenericResourceType'},
|
||||
'bar': {
|
||||
'type': 'GenericResourceType',
|
||||
'depends_on': 'foo',
|
||||
}
|
||||
}
|
||||
})
|
||||
stack = parser.Stack(utils.dummy_context(), 'test', tmpl)
|
||||
|
||||
res = stack['bar']
|
||||
res.add_dependencies(self.deps)
|
||||
graph = self.deps.graph()
|
||||
|
||||
self.assertIn(res, graph)
|
||||
self.assertIn(stack['foo'], graph[res])
|
||||
|
||||
def test_dependson_fail(self):
|
||||
tmpl = template.Template({
|
||||
'Resources': {
|
||||
|
|
Loading…
Reference in New Issue