heat/heat/engine/parser.py

524 lines
18 KiB
Python

# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 eventlet
import json
import functools
import copy
import logging
from heat.common import exception
from heat.engine import checkeddict
from heat.engine import dependencies
from heat.engine.resources import Resource
from heat.db import api as db_api
logger = logging.getLogger('heat.engine.parser')
SECTIONS = (VERSION, DESCRIPTION, MAPPINGS,
PARAMETERS, RESOURCES, OUTPUTS) = \
('AWSTemplateFormatVersion', 'Description', 'Mappings',
'Parameters', 'Resources', 'Outputs')
(PARAM_STACK_NAME, PARAM_REGION) = ('AWS::StackName', 'AWS::Region')
class Parameters(checkeddict.CheckedDict):
'''
The parameters of a stack, with type checking, defaults &c. specified by
the stack's template.
'''
def __init__(self, stack_name, template, user_params={}):
'''
Create the parameter container for a stack from the stack name and
template, optionally setting the initial set of parameters.
'''
checkeddict.CheckedDict.__init__(self, PARAMETERS)
self._init_schemata(template[PARAMETERS])
self[PARAM_STACK_NAME] = stack_name
self.update(user_params)
def _init_schemata(self, schemata):
'''
Initialise the parameter schemata with the pseudo-parameters and the
list of schemata obtained from the template.
'''
self.addschema(PARAM_STACK_NAME, {"Description": "AWS StackName",
"Type": "String"})
self.addschema(PARAM_REGION, {
"Description": "AWS Regions",
"Default": "ap-southeast-1",
"Type": "String",
"AllowedValues": ["us-east-1", "us-west-1", "us-west-2",
"sa-east-1", "eu-west-1", "ap-southeast-1",
"ap-northeast-1"],
"ConstraintDescription": "must be a valid EC2 instance type.",
})
for param, schema in schemata.items():
self.addschema(param, copy.deepcopy(schema))
def user_parameters(self):
'''
Return a dictionary of all the parameters passed in by the user
'''
return dict((k, v['Value']) for k, v in self.data.iteritems()
if 'Value' in v)
class Template(object):
'''A stack template.'''
def __init__(self, template, template_id=None):
'''
Initialise the template with a JSON object and a set of Parameters
'''
self.id = template_id
self.t = template
self.maps = self[MAPPINGS]
@classmethod
def load(cls, context, template_id):
'''Retrieve a Template with the given ID from the database'''
t = db_api.raw_template_get(context, template_id)
return cls(t.template, template_id)
def store(self, context=None):
'''Store the Template in the database and return its ID'''
if self.id is None:
rt = {'template': self.t}
new_rt = db_api.raw_template_create(context, rt)
self.id = new_rt.id
return self.id
def __getitem__(self, section):
'''Get the relevant section in the template'''
if section not in SECTIONS:
raise KeyError('"%s" is not a valid template section' % section)
if section == VERSION:
return self.t[section]
if section == DESCRIPTION:
default = 'No description'
else:
default = {}
return self.t.get(section, default)
def resolve_find_in_map(self, s):
'''
Resolve constructs of the form { "Fn::FindInMap" : [ "mapping",
"key",
"value" ] }
'''
def handle_find_in_map(args):
try:
name, key, value = args
return self.maps[name][key][value]
except (ValueError, TypeError) as ex:
raise KeyError(str(ex))
return _resolve(lambda k, v: k == 'Fn::FindInMap',
handle_find_in_map, s)
@staticmethod
def resolve_availability_zones(s):
'''
looking for { "Fn::GetAZs" : "str" }
'''
def match_get_az(key, value):
return (key == 'Fn::GetAZs' and
isinstance(value, basestring))
def handle_get_az(ref):
return ['nova']
return _resolve(match_get_az, handle_get_az, s)
@staticmethod
def resolve_param_refs(s, parameters):
'''
Resolve constructs of the form { "Ref" : "string" }
'''
def match_param_ref(key, value):
return (key == 'Ref' and
isinstance(value, basestring) and
value in parameters)
def handle_param_ref(ref):
try:
return parameters[ref]
except (KeyError, ValueError):
raise exception.UserParameterMissing(key=ref)
return _resolve(match_param_ref, handle_param_ref, s)
@staticmethod
def resolve_resource_refs(s, resources):
'''
Resolve constructs of the form { "Ref" : "resource" }
'''
def match_resource_ref(key, value):
return key == 'Ref' and value in resources
def handle_resource_ref(arg):
return resources[arg].FnGetRefId()
return _resolve(match_resource_ref, handle_resource_ref, s)
@staticmethod
def resolve_attributes(s, resources):
'''
Resolve constructs of the form { "Fn::GetAtt" : [ "WebServer",
"PublicIp" ] }
'''
def handle_getatt(args):
resource, att = args
try:
return resources[resource].FnGetAtt(att)
except KeyError:
raise exception.InvalidTemplateAttribute(resource=resource,
key=att)
return _resolve(lambda k, v: k == 'Fn::GetAtt', handle_getatt, s)
@staticmethod
def resolve_joins(s):
'''
Resolve constructs of the form { "Fn::Join" : [ "delim", [ "str1",
"str2" ] }
'''
def handle_join(args):
if not isinstance(args, (list, tuple)):
raise TypeError('Arguments to "Fn::Join" must be a list')
delim, strings = args
if not isinstance(strings, (list, tuple)):
raise TypeError('Arguments to "Fn::Join" not fully resolved')
return delim.join(strings)
return _resolve(lambda k, v: k == 'Fn::Join', handle_join, s)
@staticmethod
def resolve_base64(s):
'''
Resolve constructs of the form { "Fn::Base64" : "string" }
'''
def handle_base64(string):
if not isinstance(string, basestring):
raise TypeError('Arguments to "Fn::Base64" not fully resolved')
return string
return _resolve(lambda k, v: k == 'Fn::Base64', handle_base64, s)
class Stack(object):
IN_PROGRESS = 'IN_PROGRESS'
CREATE_FAILED = 'CREATE_FAILED'
CREATE_COMPLETE = 'CREATE_COMPLETE'
DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS'
DELETE_FAILED = 'DELETE_FAILED'
DELETE_COMPLETE = 'DELETE_COMPLETE'
def __init__(self, context, stack_name, template, parameters=None,
stack_id=None):
'''
Initialise from a context, name, Template object and (optionally)
Parameters object. The database ID may also be initialised, if the
stack is already in the database.
'''
self.id = stack_id
self.context = context
self.t = template
self.name = stack_name
if parameters is None:
parameters = Parameters(stack_name, template)
self.parameters = parameters
self.outputs = self.resolve_static_data(self.t[OUTPUTS])
self.resources = dict((name,
Resource(name, data, self))
for (name, data) in self.t[RESOURCES].items())
self.dependencies = self._get_dependencies(self.resources.itervalues())
@staticmethod
def _get_dependencies(resources):
'''Return the dependency graph for a list of resources'''
deps = dependencies.Dependencies()
for resource in resources:
resource.add_dependencies(deps)
return deps
@classmethod
def load(cls, context, stack_id):
'''Retrieve a Stack from the database'''
s = db_api.stack_get(context, stack_id)
template = Template.load(context, s.raw_template_id)
params = Parameters(s.name, template, s.parameters)
stack = cls(context, s.name, template, params, stack_id)
return stack
def store(self, owner=None):
'''Store the stack in the database and return its ID'''
if self.id is None:
new_creds = db_api.user_creds_create(self.context.to_dict())
s = {'name': self.name,
'raw_template_id': self.t.store(),
'parameters': self.parameters.user_parameters(),
'owner_id': owner and owner.id,
'user_creds_id': new_creds.id,
'username': self.context.username}
new_s = db_api.stack_create(self.context, s)
self.id = new_s.id
return self.id
def __iter__(self):
'''
Return an iterator over this template's resources in the order that
they should be started.
'''
return iter(self.dependencies)
def __reversed__(self):
'''
Return an iterator over this template's resources in the order that
they should be stopped.
'''
return reversed(self.dependencies)
def __len__(self):
'''Return the number of resources'''
return len(self.resources)
def __getitem__(self, key):
'''Get the resource with the specified name.'''
return self.resources[key]
def __contains__(self, key):
'''Determine whether the stack contains the specified resource'''
return key in self.resources
def keys(self):
'''Return a list of resource keys for the stack'''
return self.resources.keys()
def __str__(self):
'''Return a human-readable string representation of the stack'''
return 'Stack "%s"' % self.name
def stack_id(self):
'''
Return a unique ID for this stack suitable for displaying to the
user
'''
return '/'.join([self.name, str(self.id)])
def validate(self):
'''
http://docs.amazonwebservices.com/AWSCloudFormation/latest/\
APIReference/API_ValidateTemplate.html
'''
# TODO(sdake) Should return line number of invalid reference
for res in self:
try:
result = res.validate()
except Exception as ex:
logger.exception('validate')
result = str(ex)
if result:
err_str = 'Malformed Query Response %s' % result
response = {'Description': err_str,
'Parameters': []}
return response
def format_param(p):
return {'NoEcho': 'false',
'ParameterKey': p,
'Description': self.parameters.get_attr(p, 'Description'),
'DefaultValue': self.parameters.get_attr(p, 'Default')}
response = {'Description': 'Successfully validated',
'Parameters': [format_param(p) for p in self.parameters]}
return response
def state_set(self, new_status, reason):
'''Update the stack state in the database'''
if self.id is None:
return
stack = db_api.stack_get(self.context, self.id)
stack.update_and_save({'status': new_status,
'status_reason': reason})
def create(self, timeout_in_minutes=60):
'''
Create the stack and all of the resources.
Creation will fail if it exceeds the specified timeout. The default is
60 minutes.
'''
self.state_set(self.IN_PROGRESS, 'Stack creation started')
stack_status = self.CREATE_COMPLETE
reason = 'Stack successfully created'
res = None
with eventlet.Timeout(timeout_in_minutes * 60) as tmo:
try:
for res in self:
if stack_status != self.CREATE_FAILED:
result = res.create()
if result:
stack_status = self.CREATE_FAILED
reason = 'Resource %s failed with: %s' % (str(res),
result)
else:
res.state_set(res.CREATE_FAILED,
'Stack creation aborted')
except eventlet.Timeout as t:
if t is tmo:
stack_status = self.CREATE_FAILED
reason = 'Timed out waiting for %s' % str(res)
else:
# not my timeout
raise
self.state_set(stack_status, reason)
def delete(self):
'''
Delete all of the resources, and then the stack itself.
'''
self.state_set(self.DELETE_IN_PROGRESS, 'Stack deletion started')
failures = []
for res in reversed(self):
result = res.destroy()
if result:
failures.append(str(res))
if failures:
self.state_set(self.DELETE_FAILED,
'Failed to delete ' + ', '.join(failures))
else:
self.state_set(self.DELETE_COMPLETE, 'Deleted successfully')
db_api.stack_delete(self.context, self.id)
def output(self, key):
value = self.outputs[key].get('Value', '')
return self.resolve_runtime_data(value)
def get_outputs(self):
def output_dict(k):
return {'Description': self.outputs[k].get('Description',
'No description given'),
'OutputKey': k,
'OutputValue': self.output(k)}
return [output_dict(key) for key in self.outputs]
def restart_resource(self, resource_name):
'''
stop resource_name and all that depend on it
start resource_name and all that depend on it
'''
deps = self.dependencies[self[resource_name]]
failed = False
for res in reversed(deps):
try:
res.delete()
re = db_api.resource_get(self.context, res.id)
re.delete()
except Exception as ex:
failed = True
logger.error('delete: %s' % str(ex))
for res in deps:
if not failed:
try:
res.create()
except Exception as ex:
logger.exception('create')
failed = True
else:
res.state_set(res.CREATE_FAILED, 'Resource restart aborted')
# TODO(asalkeld) if any of this fails we Should
# restart the whole stack
def resolve_static_data(self, snippet):
return transform(snippet,
[functools.partial(self.t.resolve_param_refs,
parameters=self.parameters),
self.t.resolve_availability_zones,
self.t.resolve_find_in_map])
def resolve_runtime_data(self, snippet):
return transform(snippet,
[functools.partial(self.t.resolve_resource_refs,
resources=self.resources),
functools.partial(self.t.resolve_attributes,
resources=self.resources),
self.t.resolve_joins,
self.t.resolve_base64])
def transform(data, transformations):
'''
Apply each of the transformation functions in the supplied list to the data
in turn.
'''
for t in transformations:
data = t(data)
return data
def _resolve(match, handle, snippet):
'''
Resolve constructs in a snippet of a template. The supplied match function
should return True if a particular key-value pair should be substituted,
and the handle function should return the correct substitution when passed
the argument list as parameters.
Returns a copy of the original snippet with the substitutions performed.
'''
recurse = lambda s: _resolve(match, handle, s)
if isinstance(snippet, dict):
if len(snippet) == 1:
k, v = snippet.items()[0]
if match(k, v):
return handle(recurse(v))
return dict((k, recurse(v)) for k, v in snippet.items())
elif isinstance(snippet, list):
return [recurse(v) for v in snippet]
return snippet