From fa0a133a2f477836f912ae0d34da8d799b5f4f51 Mon Sep 17 00:00:00 2001 From: Monty Taylor Date: Sat, 21 Jan 2017 17:31:03 +0100 Subject: [PATCH] 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 --- shade/_heat/__init__.py | 0 shade/_heat/environment_format.py | 56 ++++++ shade/_heat/event_utils.py | 177 +++++++++++++++++ shade/_heat/template_format.py | 69 +++++++ shade/_heat/template_utils.py | 314 ++++++++++++++++++++++++++++++ shade/_heat/utils.py | 61 ++++++ shade/openstackcloud.py | 4 +- shade/tests/unit/test_stack.py | 5 +- 8 files changed, 681 insertions(+), 5 deletions(-) create mode 100644 shade/_heat/__init__.py create mode 100644 shade/_heat/environment_format.py create mode 100644 shade/_heat/event_utils.py create mode 100644 shade/_heat/template_format.py create mode 100644 shade/_heat/template_utils.py create mode 100644 shade/_heat/utils.py diff --git a/shade/_heat/__init__.py b/shade/_heat/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/shade/_heat/environment_format.py b/shade/_heat/environment_format.py new file mode 100644 index 000000000..56bc2c1c0 --- /dev/null +++ b/shade/_heat/environment_format.py @@ -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 diff --git a/shade/_heat/event_utils.py b/shade/_heat/event_utils.py new file mode 100644 index 000000000..ab3c27cd0 --- /dev/null +++ b/shade/_heat/event_utils.py @@ -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) diff --git a/shade/_heat/template_format.py b/shade/_heat/template_format.py new file mode 100644 index 000000000..4bb6098dc --- /dev/null +++ b/shade/_heat/template_format.py @@ -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 diff --git a/shade/_heat/template_utils.py b/shade/_heat/template_utils.py new file mode 100644 index 000000000..1b2aeb766 --- /dev/null +++ b/shade/_heat/template_utils.py @@ -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) diff --git a/shade/_heat/utils.py b/shade/_heat/utils.py new file mode 100644 index 000000000..24cb0b071 --- /dev/null +++ b/shade/_heat/utils.py @@ -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) diff --git a/shade/openstackcloud.py b/shade/openstackcloud.py index e695c33d7..7d6f18f23 100644 --- a/shade/openstackcloud.py +++ b/shade/openstackcloud.py @@ -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 diff --git a/shade/tests/unit/test_stack.py b/shade/tests/unit/test_stack.py index 62a3458b4..f0d1e2f51 100644 --- a/shade/tests/unit/test_stack.py +++ b/shade/tests/unit/test_stack.py @@ -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