Copy in needed template processing utils from heatclient

There is a LOT of client-side file processing that goes on before
uploading heat templates to the cloud. Rather than reimplement it all
from scratch, copy in the relevant bits. With this done, removing
heatclient itself should be fairly easy. This will also allow us to fix
a long-standing bug, which is that heat event polling bypassed the
TaskManager since the utils make their own API calls.

Depends-On: Id608025d610de2099d7be37dcff35de33c10b9d5
Change-Id: I384f81b6198f874e78a434515123f955017e0172
This commit is contained in:
Monty Taylor 2017-01-21 17:31:03 +01:00
parent efe7b8d469
commit fa0a133a2f
8 changed files with 681 additions and 5 deletions

0
shade/_heat/__init__.py Normal file
View File

View File

@ -0,0 +1,56 @@
# 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 yaml
from shade._heat import template_format
SECTIONS = (
PARAMETER_DEFAULTS, PARAMETERS, RESOURCE_REGISTRY,
ENCRYPTED_PARAM_NAMES, EVENT_SINKS,
PARAMETER_MERGE_STRATEGIES
) = (
'parameter_defaults', 'parameters', 'resource_registry',
'encrypted_param_names', 'event_sinks',
'parameter_merge_strategies'
)
def parse(env_str):
"""Takes a string and returns a dict containing the parsed structure.
This includes determination of whether the string is using the
YAML format.
"""
try:
env = yaml.load(env_str, Loader=template_format.yaml_loader)
except yaml.YAMLError:
# NOTE(prazumovsky): we need to return more informative error for
# user, so use SafeLoader, which return error message with template
# snippet where error has been occurred.
try:
env = yaml.load(env_str, Loader=yaml.SafeLoader)
except yaml.YAMLError as yea:
raise ValueError(yea)
else:
if env is None:
env = {}
elif not isinstance(env, dict):
raise ValueError(
'The environment is not a valid YAML mapping data type.')
for param in env:
if param not in SECTIONS:
raise ValueError('environment has wrong section "%s"' % param)
return env

177
shade/_heat/event_utils.py Normal file
View File

@ -0,0 +1,177 @@
# Copyright 2015 Red Hat 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 time
from shade._heat import utils
import heatclient.exc as exc
def get_events(hc, stack_id, event_args, nested_depth=0,
marker=None, limit=None):
event_args = dict(event_args)
if marker:
event_args['marker'] = marker
if limit:
event_args['limit'] = limit
if not nested_depth:
# simple call with no nested_depth
return _get_stack_events(hc, stack_id, event_args)
# assume an API which supports nested_depth
event_args['nested_depth'] = nested_depth
events = _get_stack_events(hc, stack_id, event_args)
if not events:
return events
first_links = getattr(events[0], 'links', [])
root_stack_link = [l for l in first_links
if l.get('rel') == 'root_stack']
if root_stack_link:
# response has a root_stack link, indicating this is an API which
# supports nested_depth
return events
# API doesn't support nested_depth, do client-side paging and recursive
# event fetch
marker = event_args.pop('marker', None)
limit = event_args.pop('limit', None)
event_args.pop('nested_depth', None)
events = _get_stack_events(hc, stack_id, event_args)
events.extend(_get_nested_events(hc, nested_depth,
stack_id, event_args))
# Because there have been multiple stacks events mangled into
# one list, we need to sort before passing to print_list
# Note we can't use the prettytable sortby_index here, because
# the "start" option doesn't allow post-sort slicing, which
# will be needed to make "--marker" work for nested_depth lists
events.sort(key=lambda x: x.event_time)
# Slice the list if marker is specified
if marker:
try:
marker_index = [e.id for e in events].index(marker)
events = events[marker_index:]
except ValueError:
pass
# Slice the list if limit is specified
if limit:
limit_index = min(int(limit), len(events))
events = events[:limit_index]
return events
def _get_nested_ids(hc, stack_id):
nested_ids = []
try:
resources = hc.resources.list(stack_id=stack_id)
except exc.HTTPNotFound:
raise exc.CommandError('Stack not found: %s' % stack_id)
for r in resources:
nested_id = utils.resource_nested_identifier(r)
if nested_id:
nested_ids.append(nested_id)
return nested_ids
def _get_nested_events(hc, nested_depth, stack_id, event_args):
# FIXME(shardy): this is very inefficient, we should add nested_depth to
# the event_list API in a future heat version, but this will be required
# until kilo heat is EOL.
nested_ids = _get_nested_ids(hc, stack_id)
nested_events = []
for n_id in nested_ids:
stack_events = _get_stack_events(hc, n_id, event_args)
if stack_events:
nested_events.extend(stack_events)
if nested_depth > 1:
next_depth = nested_depth - 1
nested_events.extend(_get_nested_events(
hc, next_depth, n_id, event_args))
return nested_events
def _get_stack_events(hc, stack_id, event_args):
event_args['stack_id'] = stack_id
try:
events = hc.events.list(**event_args)
except exc.HTTPNotFound as ex:
# it could be the stack or resource that is not found
# just use the message that the server sent us.
raise exc.CommandError(str(ex))
else:
# Show which stack the event comes from (for nested events)
for e in events:
e.stack_name = stack_id.split("/")[0]
return events
def poll_for_events(hc, stack_name, action=None, poll_period=5, marker=None,
nested_depth=0):
"""Continuously poll events and logs for performed action on stack."""
if action:
stop_status = ('%s_FAILED' % action, '%s_COMPLETE' % action)
stop_check = lambda a: a in stop_status
else:
stop_check = lambda a: a.endswith('_COMPLETE') or a.endswith('_FAILED')
no_event_polls = 0
msg_template = "\n Stack %(name)s %(status)s \n"
def is_stack_event(event):
if getattr(event, 'resource_name', '') != stack_name:
return False
phys_id = getattr(event, 'physical_resource_id', '')
links = dict((l.get('rel'),
l.get('href')) for l in getattr(event, 'links', []))
stack_id = links.get('stack', phys_id).rsplit('/', 1)[-1]
return stack_id == phys_id
while True:
events = get_events(hc, stack_id=stack_name, nested_depth=nested_depth,
event_args={'sort_dir': 'asc',
'marker': marker})
if len(events) == 0:
no_event_polls += 1
else:
no_event_polls = 0
# set marker to last event that was received.
marker = getattr(events[-1], 'id', None)
for event in events:
# check if stack event was also received
if is_stack_event(event):
stack_status = getattr(event, 'resource_status', '')
msg = msg_template % dict(
name=stack_name, status=stack_status)
if stop_check(stack_status):
return stack_status, msg
if no_event_polls >= 2:
# after 2 polls with no events, fall back to a stack get
stack = hc.stacks.get(stack_name, resolve_outputs=False)
stack_status = stack.stack_status
msg = msg_template % dict(
name=stack_name, status=stack_status)
if stop_check(stack_status):
return stack_status, msg
# go back to event polling again
no_event_polls = 0
time.sleep(poll_period)

View File

@ -0,0 +1,69 @@
# 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 json
import yaml
if hasattr(yaml, 'CSafeLoader'):
yaml_loader = yaml.CSafeLoader
else:
yaml_loader = yaml.SafeLoader
if hasattr(yaml, 'CSafeDumper'):
yaml_dumper = yaml.CSafeDumper
else:
yaml_dumper = yaml.SafeDumper
def _construct_yaml_str(self, node):
# Override the default string handling function
# to always return unicode objects
return self.construct_scalar(node)
yaml_loader.add_constructor(u'tag:yaml.org,2002:str', _construct_yaml_str)
# Unquoted dates like 2013-05-23 in yaml files get loaded as objects of type
# datetime.data which causes problems in API layer when being processed by
# openstack.common.jsonutils. Therefore, make unicode string out of timestamps
# until jsonutils can handle dates.
yaml_loader.add_constructor(u'tag:yaml.org,2002:timestamp',
_construct_yaml_str)
def parse(tmpl_str):
"""Takes a string and returns a dict containing the parsed structure.
This includes determination of whether the string is using the
JSON or YAML format.
"""
# strip any whitespace before the check
tmpl_str = tmpl_str.strip()
if tmpl_str.startswith('{'):
tpl = json.loads(tmpl_str)
else:
try:
tpl = yaml.load(tmpl_str, Loader=yaml_loader)
except yaml.YAMLError:
# NOTE(prazumovsky): we need to return more informative error for
# user, so use SafeLoader, which return error message with template
# snippet where error has been occurred.
try:
tpl = yaml.load(tmpl_str, Loader=yaml.SafeLoader)
except yaml.YAMLError as yea:
raise ValueError(yea)
else:
if tpl is None:
tpl = {}
# Looking for supported version keys in the loaded template
if not ('HeatTemplateFormatVersion' in tpl
or 'heat_template_version' in tpl
or 'AWSTemplateFormatVersion' in tpl):
raise ValueError("Template format version not found.")
return tpl

View File

@ -0,0 +1,314 @@
# Copyright 2012 OpenStack Foundation
#
# 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 collections
import json
import six
from six.moves.urllib import parse
from six.moves.urllib import request
from shade._heat import environment_format
from shade._heat import template_format
from shade._heat import utils
from shade import exc
def get_template_contents(template_file=None, template_url=None,
template_object=None, object_request=None,
files=None, existing=False):
is_object = False
tpl = None
# Transform a bare file path to a file:// URL.
if template_file:
template_url = utils.normalise_file_path_to_url(template_file)
if template_url:
tpl = request.urlopen(template_url).read()
elif template_object:
is_object = True
template_url = template_object
tpl = object_request and object_request('GET',
template_object)
elif existing:
return {}, None
else:
raise exc.OpenStackCloudException(
'Must provide one of template_file,'
' template_url or template_object')
if not tpl:
raise exc.OpenStackCloudException(
'Could not fetch template from %s' % template_url)
try:
if isinstance(tpl, six.binary_type):
tpl = tpl.decode('utf-8')
template = template_format.parse(tpl)
except ValueError as e:
raise exc.OpenStackCloudException(
'Error parsing template %(url)s %(error)s' %
{'url': template_url, 'error': e})
tmpl_base_url = utils.base_url_for_url(template_url)
if files is None:
files = {}
resolve_template_get_files(template, files, tmpl_base_url, is_object,
object_request)
return files, template
def resolve_template_get_files(template, files, template_base_url,
is_object=False, object_request=None):
def ignore_if(key, value):
if key != 'get_file' and key != 'type':
return True
if not isinstance(value, six.string_types):
return True
if (key == 'type' and
not value.endswith(('.yaml', '.template'))):
return True
return False
def recurse_if(value):
return isinstance(value, (dict, list))
get_file_contents(template, files, template_base_url,
ignore_if, recurse_if, is_object, object_request)
def is_template(file_content):
try:
if isinstance(file_content, six.binary_type):
file_content = file_content.decode('utf-8')
template_format.parse(file_content)
except (ValueError, TypeError):
return False
return True
def get_file_contents(from_data, files, base_url=None,
ignore_if=None, recurse_if=None,
is_object=False, object_request=None):
if recurse_if and recurse_if(from_data):
if isinstance(from_data, dict):
recurse_data = six.itervalues(from_data)
else:
recurse_data = from_data
for value in recurse_data:
get_file_contents(value, files, base_url, ignore_if, recurse_if,
is_object, object_request)
if isinstance(from_data, dict):
for key, value in six.iteritems(from_data):
if ignore_if and ignore_if(key, value):
continue
if base_url and not base_url.endswith('/'):
base_url = base_url + '/'
str_url = parse.urljoin(base_url, value)
if str_url not in files:
if is_object and object_request:
file_content = object_request('GET', str_url)
else:
file_content = utils.read_url_content(str_url)
if is_template(file_content):
if is_object:
template = get_template_contents(
template_object=str_url, files=files,
object_request=object_request)[1]
else:
template = get_template_contents(
template_url=str_url, files=files)[1]
file_content = json.dumps(template)
files[str_url] = file_content
# replace the data value with the normalised absolute URL
from_data[key] = str_url
def deep_update(old, new):
'''Merge nested dictionaries.'''
# Prevents an error if in a previous iteration
# old[k] = None but v[k] = {...},
if old is None:
old = {}
for k, v in new.items():
if isinstance(v, collections.Mapping):
r = deep_update(old.get(k, {}), v)
old[k] = r
else:
old[k] = new[k]
return old
def process_multiple_environments_and_files(env_paths=None, template=None,
template_url=None,
env_path_is_object=None,
object_request=None,
env_list_tracker=None):
"""Reads one or more environment files.
Reads in each specified environment file and returns a dictionary
of the filenames->contents (suitable for the files dict)
and the consolidated environment (after having applied the correct
overrides based on order).
If a list is provided in the env_list_tracker parameter, the behavior
is altered to take advantage of server-side environment resolution.
Specifically, this means:
* Populating env_list_tracker with an ordered list of environment file
URLs to be passed to the server
* Including the contents of each environment file in the returned
files dict, keyed by one of the URLs in env_list_tracker
:param env_paths: list of paths to the environment files to load; if
None, empty results will be returned
:type env_paths: list or None
:param template: unused; only included for API compatibility
:param template_url: unused; only included for API compatibility
:param env_list_tracker: if specified, environment filenames will be
stored within
:type env_list_tracker: list or None
:return: tuple of files dict and a dict of the consolidated environment
:rtype: tuple
"""
merged_files = {}
merged_env = {}
# If we're keeping a list of environment files separately, include the
# contents of the files in the files dict
include_env_in_files = env_list_tracker is not None
if env_paths:
for env_path in env_paths:
files, env = process_environment_and_files(
env_path=env_path,
template=template,
template_url=template_url,
env_path_is_object=env_path_is_object,
object_request=object_request,
include_env_in_files=include_env_in_files)
# 'files' looks like {"filename1": contents, "filename2": contents}
# so a simple update is enough for merging
merged_files.update(files)
# 'env' can be a deeply nested dictionary, so a simple update is
# not enough
merged_env = deep_update(merged_env, env)
if env_list_tracker is not None:
env_url = utils.normalise_file_path_to_url(env_path)
env_list_tracker.append(env_url)
return merged_files, merged_env
def process_environment_and_files(env_path=None,
template=None,
template_url=None,
env_path_is_object=None,
object_request=None,
include_env_in_files=False):
"""Loads a single environment file.
Returns an entry suitable for the files dict which maps the environment
filename to its contents.
:param env_path: full path to the file to load
:type env_path: str or None
:param include_env_in_files: if specified, the raw environment file itself
will be included in the returned files dict
:type include_env_in_files: bool
:return: tuple of files dict and the loaded environment as a dict
:rtype: (dict, dict)
"""
files = {}
env = {}
is_object = env_path_is_object and env_path_is_object(env_path)
if is_object:
raw_env = object_request and object_request('GET', env_path)
env = environment_format.parse(raw_env)
env_base_url = utils.base_url_for_url(env_path)
resolve_environment_urls(
env.get('resource_registry'),
files,
env_base_url, is_object=True, object_request=object_request)
elif env_path:
env_url = utils.normalise_file_path_to_url(env_path)
env_base_url = utils.base_url_for_url(env_url)
raw_env = request.urlopen(env_url).read()
env = environment_format.parse(raw_env)
resolve_environment_urls(
env.get('resource_registry'),
files,
env_base_url)
if include_env_in_files:
files[env_url] = json.dumps(env)
return files, env
def resolve_environment_urls(resource_registry, files, env_base_url,
is_object=False, object_request=None):
"""Handles any resource URLs specified in an environment.
:param resource_registry: mapping of type name to template filename
:type resource_registry: dict
:param files: dict to store loaded file contents into
:type files: dict
:param env_base_url: base URL to look in when loading files
:type env_base_url: str or None
"""
if resource_registry is None:
return
rr = resource_registry
base_url = rr.get('base_url', env_base_url)
def ignore_if(key, value):
if key == 'base_url':
return True
if isinstance(value, dict):
return True
if '::' in value:
# Built in providers like: "X::Compute::Server"
# don't need downloading.
return True
if key in ['hooks', 'restricted_actions']:
return True
get_file_contents(rr, files, base_url, ignore_if,
is_object=is_object, object_request=object_request)
for res_name, res_dict in six.iteritems(rr.get('resources', {})):
res_base_url = res_dict.get('base_url', base_url)
get_file_contents(
res_dict, files, res_base_url, ignore_if,
is_object=is_object, object_request=object_request)

61
shade/_heat/utils.py Normal file
View File

@ -0,0 +1,61 @@
# Copyright 2012 OpenStack Foundation
# All Rights Reserved.
#
# 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 base64
import os
from six.moves.urllib import error
from six.moves.urllib import parse
from six.moves.urllib import request
from shade import exc
def base_url_for_url(url):
parsed = parse.urlparse(url)
parsed_dir = os.path.dirname(parsed.path)
return parse.urljoin(url, parsed_dir)
def normalise_file_path_to_url(path):
if parse.urlparse(path).scheme:
return path
path = os.path.abspath(path)
return parse.urljoin('file:', request.pathname2url(path))
def read_url_content(url):
try:
# TODO(mordred) Use requests
content = request.urlopen(url).read()
except error.URLError:
raise exc.OpenStackCloudException(
'Could not fetch contents for %s' % url)
if content:
try:
content.decode('utf-8')
except ValueError:
content = base64.encodestring(content)
return content
def resource_nested_identifier(rsrc):
nested_link = [l for l in rsrc.links or []
if l.get('rel') == 'nested']
if nested_link:
nested_href = nested_link[0].get('href')
nested_identifier = nested_href.split("/")[-2:]
return "/".join(nested_identifier)

View File

@ -31,8 +31,6 @@ from six.moves import urllib
import cinderclient.exceptions as cinder_exceptions
import heatclient.client
import magnumclient.exceptions as magnum_exceptions
from heatclient.common import event_utils
from heatclient.common import template_utils
from heatclient import exc as heat_exceptions
import keystoneauth1.exceptions
import keystoneclient.client
@ -46,6 +44,8 @@ import designateclient.client
from shade.exc import * # noqa
from shade import _adapter
from shade._heat import event_utils
from shade._heat import template_utils
from shade import _log
from shade import _normalize
from shade import meta

View File

@ -14,10 +14,9 @@
import mock
import testtools
from heatclient.common import event_utils
from heatclient.common import template_utils
import shade
from shade._heat import event_utils
from shade._heat import template_utils
from shade import meta
from shade.tests import fakes
from shade.tests.unit import base