612a25371d
HeatStack class expects to be owned by some other and uses it to find OpenStack region name it belongs to. If there is no owner provided then the default region is used which is random for multi-region setups. MuranoPL code that was generated from HOT and TOSCA provided that owner on "deploy" phase but not on "destroy". So the stack that was successfully created earlier could never be deleted in multi-region OpenStack installations for non-default region. Change-Id: I19ca0c7129b073fdd852b22a6a7f84f7ee377ee7 Closes-Bug: #1565777
536 lines
19 KiB
Python
536 lines
19 KiB
Python
# Copyright (c) 2014 Mirantis, Inc.
|
|
#
|
|
# 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 os
|
|
import shutil
|
|
import sys
|
|
|
|
import six
|
|
import yaml
|
|
|
|
from murano.packages import exceptions
|
|
from murano.packages import package_base
|
|
|
|
RESOURCES_DIR_NAME = 'Resources/'
|
|
HOT_FILES_DIR_NAME = 'HotFiles/'
|
|
HOT_ENV_DIR_NAME = 'HotEnvironments/'
|
|
|
|
|
|
class YAQL(object):
|
|
def __init__(self, expr):
|
|
self.expr = expr
|
|
|
|
|
|
class Dumper(yaml.SafeDumper):
|
|
pass
|
|
|
|
|
|
def yaql_representer(dumper, data):
|
|
return dumper.represent_scalar(u'!yaql', data.expr)
|
|
|
|
|
|
Dumper.add_representer(YAQL, yaql_representer)
|
|
|
|
|
|
class HotPackage(package_base.PackageBase):
|
|
def __init__(self, format_name, runtime_version, source_directory,
|
|
manifest):
|
|
super(HotPackage, self).__init__(
|
|
format_name, runtime_version, source_directory, manifest)
|
|
|
|
self._translated_class = None
|
|
self._source_directory = source_directory
|
|
self._translated_ui = None
|
|
|
|
@property
|
|
def classes(self):
|
|
return self.full_name,
|
|
|
|
@property
|
|
def requirements(self):
|
|
return {}
|
|
|
|
@property
|
|
def ui(self):
|
|
if not self._translated_ui:
|
|
self._translated_ui = self._translate_ui()
|
|
return self._translated_ui
|
|
|
|
def get_class(self, name):
|
|
if name != self.full_name:
|
|
raise exceptions.PackageClassLoadError(
|
|
name, 'Class not defined in this package')
|
|
if not self._translated_class:
|
|
self._translate_class()
|
|
return self._translated_class, '<generated code>'
|
|
|
|
def _translate_class(self):
|
|
template_file = os.path.join(self._source_directory, 'template.yaml')
|
|
shutil.copy(template_file, self.get_resource(self.full_name))
|
|
|
|
if not os.path.isfile(template_file):
|
|
raise exceptions.PackageClassLoadError(
|
|
self.full_name, 'File with class definition not found')
|
|
with open(template_file) as stream:
|
|
hot = yaml.safe_load(stream)
|
|
if 'resources' not in hot:
|
|
raise exceptions.PackageFormatError('Not a HOT template')
|
|
translated = {
|
|
'Name': self.full_name,
|
|
'Extends': 'io.murano.Application'
|
|
}
|
|
|
|
hot_envs_path = os.path.join(self._source_directory,
|
|
RESOURCES_DIR_NAME,
|
|
HOT_ENV_DIR_NAME)
|
|
|
|
# if using hot environments, doing parameter validation with contracts
|
|
# will overwrite the parameters in the hot environment.
|
|
# don't validate parameters if hot environments exist.
|
|
validate_hot_parameters = (not os.path.isdir(hot_envs_path) or
|
|
not os.listdir(hot_envs_path))
|
|
|
|
parameters = HotPackage._build_properties(hot, validate_hot_parameters)
|
|
parameters.update(HotPackage._translate_outputs(hot))
|
|
translated['Properties'] = parameters
|
|
|
|
files = HotPackage._translate_files(self._source_directory)
|
|
translated.update(HotPackage._generate_workflow(hot, files))
|
|
|
|
# use default_style with double quote mark because by default PyYAML
|
|
# doesn't put any quote marks ans as a result strings with e.g. dashes
|
|
# may be interpreted as YAQL expressions upon load
|
|
self._translated_class = yaml.dump(
|
|
translated, Dumper=Dumper, default_style='"')
|
|
|
|
@staticmethod
|
|
def _build_properties(hot, validate_hot_parameters):
|
|
result = {
|
|
'generatedHeatStackName': {
|
|
'Contract': YAQL('$.string()'),
|
|
'Usage': 'Out'
|
|
},
|
|
'hotEnvironment': {
|
|
'Contract': YAQL('$.string()'),
|
|
'Usage': 'In'
|
|
},
|
|
'name': {
|
|
'Contract': YAQL('$.string().notNull()'),
|
|
'Usage': 'In',
|
|
|
|
}
|
|
}
|
|
|
|
if validate_hot_parameters:
|
|
params_dict = {}
|
|
for key, value in (hot.get('parameters') or {}).items():
|
|
param_contract = HotPackage._translate_param_to_contract(value)
|
|
params_dict[key] = param_contract
|
|
result['templateParameters'] = {
|
|
'Contract': params_dict,
|
|
'Default': {},
|
|
'Usage': 'In'
|
|
}
|
|
else:
|
|
result['templateParameters'] = {
|
|
'Contract': {},
|
|
'Default': {},
|
|
'Usage': 'In'
|
|
}
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _translate_param_to_contract(value):
|
|
contract = '$'
|
|
|
|
parameter_type = value['type']
|
|
if parameter_type in ('string', 'comma_delimited_list', 'json'):
|
|
contract += '.string()'
|
|
elif parameter_type == 'number':
|
|
contract += '.int()'
|
|
elif parameter_type == 'boolean':
|
|
contract += '.bool()'
|
|
else:
|
|
raise ValueError('Unsupported parameter type ' + parameter_type)
|
|
|
|
constraints = value.get('constraints') or []
|
|
for constraint in constraints:
|
|
translated = HotPackage._translate_constraint(constraint)
|
|
if translated:
|
|
contract += translated
|
|
|
|
result = YAQL(contract)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _translate_outputs(hot):
|
|
contract = {}
|
|
for key in six.iterkeys(hot.get('outputs') or {}):
|
|
contract[key] = YAQL("$.string()")
|
|
return {
|
|
'templateOutputs': {
|
|
'Contract': contract,
|
|
'Default': {},
|
|
'Usage': 'Out'
|
|
}
|
|
}
|
|
|
|
@staticmethod
|
|
def _translate_files(source_directory):
|
|
hot_files_path = os.path.join(source_directory,
|
|
RESOURCES_DIR_NAME,
|
|
HOT_FILES_DIR_NAME)
|
|
|
|
return HotPackage._build_hot_resources(hot_files_path)
|
|
|
|
@staticmethod
|
|
def _build_hot_resources(basedir):
|
|
result = []
|
|
if os.path.isdir(basedir):
|
|
for root, _, files in os.walk(os.path.abspath(basedir)):
|
|
for f in files:
|
|
full_path = os.path.join(root, f)
|
|
relative_path = os.path.relpath(full_path, basedir)
|
|
result.append(relative_path)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _translate_constraint(constraint):
|
|
if 'allowed_values' in constraint:
|
|
return HotPackage._translate_allowed_values_constraint(
|
|
constraint['allowed_values'])
|
|
elif 'length' in constraint:
|
|
return HotPackage._translate_length_constraint(
|
|
constraint['length'])
|
|
elif 'range' in constraint:
|
|
return HotPackage._translate_range_constraint(
|
|
constraint['range'])
|
|
elif 'allowed_pattern' in constraint:
|
|
return HotPackage._translate_allowed_pattern_constraint(
|
|
constraint['allowed_pattern'])
|
|
|
|
@staticmethod
|
|
def _translate_allowed_pattern_constraint(value):
|
|
return ".check(matches($, '{0}'))".format(value)
|
|
|
|
@staticmethod
|
|
def _translate_allowed_values_constraint(values):
|
|
return '.check($ in list({0}))'.format(
|
|
', '.join([HotPackage._format_value(v) for v in values]))
|
|
|
|
@staticmethod
|
|
def _translate_length_constraint(value):
|
|
if 'min' in value and 'max' in value:
|
|
return '.check(len($) >= {0} and len($) <= {1})'.format(
|
|
int(value['min']), int(value['max']))
|
|
elif 'min' in value:
|
|
return '.check(len($) >= {0})'.format(int(value['min']))
|
|
elif 'max' in value:
|
|
return '.check(len($) <= {0})'.format(int(value['max']))
|
|
|
|
@staticmethod
|
|
def _translate_range_constraint(value):
|
|
if 'min' in value and 'max' in value:
|
|
return '.check($ >= {0} and $ <= {1})'.format(
|
|
int(value['min']), int(value['max']))
|
|
elif 'min' in value:
|
|
return '.check($ >= {0})'.format(int(value['min']))
|
|
elif 'max' in value:
|
|
return '.check($ <= {0})'.format(int(value['max']))
|
|
|
|
@staticmethod
|
|
def _format_value(value):
|
|
if isinstance(value, six.string_types):
|
|
return str("'" + value + "'")
|
|
return str(value)
|
|
|
|
@staticmethod
|
|
def _generate_workflow(hot, files):
|
|
hot_files_map = {}
|
|
for f in files:
|
|
file_path = "$resources.string('{0}{1}')".format(
|
|
HOT_FILES_DIR_NAME, f)
|
|
hot_files_map[f] = YAQL(file_path)
|
|
|
|
hot_env = YAQL("$.hotEnvironment")
|
|
|
|
deploy = [
|
|
{YAQL('$environment'): YAQL(
|
|
"$.find('io.murano.Environment').require()"
|
|
)},
|
|
{YAQL('$reporter'): YAQL(
|
|
"new('io.murano.system.StatusReporter', "
|
|
"environment => $environment)")},
|
|
{
|
|
'If': YAQL('$.getAttr(generatedHeatStackName) = null'),
|
|
'Then': [
|
|
YAQL("$.setAttr(generatedHeatStackName, "
|
|
"'{0}_{1}'.format(randomName(), id($environment)))")
|
|
]
|
|
},
|
|
{YAQL('$stack'): YAQL(
|
|
"new('io.murano.system.HeatStack', $environment, "
|
|
"name => $.getAttr(generatedHeatStackName))")},
|
|
|
|
YAQL("$reporter.report($this, "
|
|
"'Application deployment has started')"),
|
|
|
|
{YAQL('$resources'): YAQL("new('io.murano.system.Resources')")},
|
|
|
|
{YAQL('$template'): YAQL("$resources.yaml(type($this))")},
|
|
YAQL('$stack.setTemplate($template)'),
|
|
{YAQL('$parameters'): YAQL("$.templateParameters")},
|
|
YAQL('$stack.setParameters($parameters)'),
|
|
{YAQL('$files'): hot_files_map},
|
|
YAQL('$stack.setFiles($files)'),
|
|
{YAQL('$hotEnv'): hot_env},
|
|
{
|
|
'If': YAQL("bool($hotEnv)"),
|
|
'Then': [
|
|
{YAQL('$envRelPath'): YAQL("'{0}' + $hotEnv".format(
|
|
HOT_ENV_DIR_NAME))},
|
|
{YAQL('$hotEnvContent'): YAQL("$resources.string("
|
|
"$envRelPath)")},
|
|
YAQL('$stack.setHotEnvironment($hotEnvContent)')
|
|
]
|
|
},
|
|
|
|
YAQL("$reporter.report($this, 'Stack creation has started')"),
|
|
{
|
|
'Try': [YAQL('$stack.push()')],
|
|
'Catch': [
|
|
{
|
|
'As': 'e',
|
|
'Do': [
|
|
YAQL("$reporter.report_error($this, $e.message)"),
|
|
{'Rethrow': None}
|
|
]
|
|
}
|
|
],
|
|
'Else': [
|
|
{YAQL('$.templateOutputs'): YAQL('$stack.output()')},
|
|
YAQL("$reporter.report($this, "
|
|
"'Stack was successfully created')"),
|
|
|
|
YAQL("$reporter.report($this, "
|
|
"'Application deployment has finished')"),
|
|
]
|
|
}
|
|
]
|
|
|
|
destroy = [
|
|
{YAQL('$environment'): YAQL(
|
|
"$.find('io.murano.Environment').require()"
|
|
)},
|
|
{YAQL('$stack'): YAQL(
|
|
"new('io.murano.system.HeatStack', $environment, "
|
|
"name => $.getAttr(generatedHeatStackName))")},
|
|
YAQL('$stack.delete()')
|
|
]
|
|
|
|
return {
|
|
'Methods': {
|
|
'deploy': {
|
|
'Body': deploy
|
|
},
|
|
'destroy': {
|
|
'Body': destroy
|
|
}
|
|
}
|
|
}
|
|
|
|
@staticmethod
|
|
def _translate_ui_parameters(hot, title):
|
|
groups = hot.get('parameter_groups', [])
|
|
result_groups = []
|
|
|
|
predefined_fields = [
|
|
{
|
|
'name': 'title',
|
|
'type': 'string',
|
|
'required': False,
|
|
'hidden': True,
|
|
'description': title
|
|
},
|
|
{
|
|
'name': 'name',
|
|
'type': 'string',
|
|
'label': 'Application Name',
|
|
'required': True,
|
|
'description':
|
|
'Enter a desired name for the application.'
|
|
' Just A-Z, a-z, 0-9, and dash are allowed'
|
|
}
|
|
]
|
|
used_parameters = set()
|
|
hot_parameters = hot.get('parameters') or {}
|
|
for group in groups:
|
|
fields = []
|
|
properties = []
|
|
for parameter in group.get('parameters', []):
|
|
parameter_value = hot_parameters.get(parameter)
|
|
if parameter_value:
|
|
fields.append(HotPackage._translate_ui_parameter(
|
|
parameter, parameter_value))
|
|
used_parameters.add(parameter)
|
|
properties.append(parameter)
|
|
result_groups.append((fields, properties))
|
|
|
|
rest_group = []
|
|
properties = []
|
|
for key, value in six.iteritems(hot_parameters):
|
|
if key not in used_parameters:
|
|
rest_group.append(HotPackage._translate_ui_parameter(
|
|
key, value))
|
|
properties.append(key)
|
|
if rest_group:
|
|
result_groups.append((rest_group, properties))
|
|
|
|
result_groups.insert(0, (predefined_fields, ['name']))
|
|
return result_groups
|
|
|
|
@staticmethod
|
|
def _translate_ui_parameter(name, parameter_spec):
|
|
translated = {
|
|
'name': name,
|
|
'label': name.title().replace('_', ' ')
|
|
}
|
|
parameter_type = parameter_spec['type']
|
|
if parameter_type == 'number':
|
|
translated['type'] = 'integer'
|
|
elif parameter_type == 'boolean':
|
|
translated['type'] = 'boolean'
|
|
else:
|
|
# string, json, and comma_delimited_list parameters are all
|
|
# displayed as strings in UI. Any unsupported parameter would also
|
|
# be displayed as strings.
|
|
translated['type'] = 'string'
|
|
|
|
label = parameter_spec.get('label')
|
|
if label:
|
|
translated['label'] = label
|
|
|
|
if 'description' in parameter_spec:
|
|
translated['description'] = parameter_spec['description']
|
|
|
|
if 'default' in parameter_spec:
|
|
translated['initial'] = parameter_spec['default']
|
|
translated['required'] = False
|
|
else:
|
|
translated['required'] = True
|
|
|
|
constraints = parameter_spec.get('constraints') or []
|
|
translated_constraints = []
|
|
|
|
for constraint in constraints:
|
|
if 'length' in constraint:
|
|
spec = constraint['length']
|
|
if 'min' in spec:
|
|
translated['minLength'] = max(
|
|
translated.get('minLength', -sys.maxint - 1),
|
|
int(spec['min']))
|
|
if 'max' in spec:
|
|
translated['maxLength'] = min(
|
|
translated.get('maxLength', sys.maxint),
|
|
int(spec['max']))
|
|
|
|
elif 'range' in constraint:
|
|
spec = constraint['range']
|
|
if 'min' in spec and 'max' in spec:
|
|
ui_constraint = {
|
|
'expr': YAQL('$ >= {0} and $ <= {1}'.format(
|
|
spec['min'], spec['max']))
|
|
}
|
|
elif 'min' in spec:
|
|
ui_constraint = {
|
|
'expr': YAQL('$ >= {0}'.format(spec['min']))
|
|
}
|
|
else:
|
|
ui_constraint = {
|
|
'expr': YAQL('$ <= {0}'.format(spec['max']))
|
|
}
|
|
if 'description' in constraint:
|
|
ui_constraint['message'] = constraint['description']
|
|
translated_constraints.append(ui_constraint)
|
|
|
|
elif 'allowed_values' in constraint:
|
|
values = constraint['allowed_values']
|
|
ui_constraint = {
|
|
'expr': YAQL('$ in list({0})'.format(', '.join(
|
|
[HotPackage._format_value(v) for v in values])))
|
|
}
|
|
if 'description' in constraint:
|
|
ui_constraint['message'] = constraint['description']
|
|
translated_constraints.append(ui_constraint)
|
|
|
|
elif 'allowed_pattern' in constraint:
|
|
pattern = constraint['allowed_pattern']
|
|
ui_constraint = {
|
|
'expr': {
|
|
'regexpValidator': pattern
|
|
}
|
|
}
|
|
if 'description' in constraint:
|
|
ui_constraint['message'] = constraint['description']
|
|
translated_constraints.append(ui_constraint)
|
|
|
|
if translated_constraints:
|
|
translated['validators'] = translated_constraints
|
|
|
|
return translated
|
|
|
|
@staticmethod
|
|
def _generate_application_ui(groups, type_name):
|
|
app = {
|
|
'?': {
|
|
'type': type_name
|
|
}
|
|
}
|
|
for i, record in enumerate(groups):
|
|
if i == 0:
|
|
section = app
|
|
else:
|
|
section = app.setdefault('templateParameters', {})
|
|
for property_name in record[1]:
|
|
section[property_name] = YAQL(
|
|
'$.group{0}.{1}'.format(i, property_name))
|
|
app['name'] = YAQL('$.group0.name')
|
|
|
|
return app
|
|
|
|
def _translate_ui(self):
|
|
template_file = os.path.join(self._source_directory, 'template.yaml')
|
|
|
|
if not os.path.isfile(template_file):
|
|
raise exceptions.PackageClassLoadError(
|
|
self.full_name, 'File with class definition not found')
|
|
with open(template_file) as stream:
|
|
hot = yaml.safe_load(stream)
|
|
|
|
groups = HotPackage._translate_ui_parameters(hot, self.description)
|
|
forms = []
|
|
for i, record in enumerate(groups):
|
|
forms.append({'group{0}'.format(i): {'fields': record[0]}})
|
|
|
|
translated = {
|
|
'Version': 2,
|
|
'Application': HotPackage._generate_application_ui(
|
|
groups, self.full_name),
|
|
'Forms': forms
|
|
}
|
|
|
|
# see comment above about default_style
|
|
return yaml.dump(translated, Dumper=Dumper, default_style='"')
|