saltstack/salt

View on GitHub
salt/modules/heat.py

Summary

Maintainability
F
2 wks
Test Coverage
# -*- coding: utf-8 -*-
'''
Module for handling OpenStack Heat calls

.. versionadded:: 2017.7.0

:depends:   - heatclient Python module
:configuration: This module is not usable until the user, password, tenant, and
    auth URL are specified either in a pillar or in the minion's config file.
    For example::

        keystone.user: admin
        keystone.password: verybadpass
        keystone.tenant: admin
        keystone.insecure: False   #(optional)
        keystone.auth_url: 'http://127.0.0.1:5000/v2.0/'
        # Optional
        keystone.region_name: 'RegionOne'

    If configuration for multiple OpenStack accounts is required, they can be
    set up as different configuration profiles:
    For example::

        openstack1:
          keystone.user: admin
          keystone.password: verybadpass
          keystone.tenant: admin
          keystone.auth_url: 'http://127.0.0.1:5000/v2.0/'

        openstack2:
          keystone.user: admin
          keystone.password: verybadpass
          keystone.tenant: admin
          keystone.auth_url: 'http://127.0.0.2:5000/v2.0/'

    With this configuration in place, any of the heat functions can make use of
    a configuration profile by declaring it explicitly.
    For example::

        salt '*' heat.flavor_list profile=openstack1
'''

# Import Python libs
from __future__ import absolute_import, print_function, unicode_literals
import time
import logging

# Import Salt libs
from salt.exceptions import SaltInvocationError
from salt.ext import six
import salt.utils.files
import salt.utils.json
import salt.utils.stringutils
import salt.utils.versions
import salt.utils.yaml

# pylint: disable=import-error
HAS_HEAT = False
try:
    import heatclient
    HAS_HEAT = True
except ImportError:
    pass

HAS_OSLO = False
try:
    from oslo_serialization import jsonutils
    HAS_OSLO = True
except ImportError:
    pass

SECTIONS = (
    PARAMETER_DEFAULTS,
    PARAMETERS,
    RESOURCE_REGISTRY,
    EVENT_SINKS) = (
    'parameter_defaults',
    'parameters',
    'resource_registry',
    'event_sinks')

logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger(__name__)


def __virtual__():
    '''
    Only load this module if heat
    is installed on this minion.
    '''
    if HAS_HEAT and HAS_OSLO:
        return 'heat'
    return (False, 'The heat execution module cannot be loaded: '
                   'the heatclient and oslo_serialization'
                   # 'the heatclient and keystoneclient and oslo_serialization'
                   ' python library is not available.')


__opts__ = {}


def _auth(profile=None, api_version=1, **connection_args):
    '''
    Set up heat credentials, returns
    `heatclient.client.Client`. Optional parameter
    "api_version" defaults to 1.

    Only intended to be used within heat-enabled modules
    '''

    if profile:
        prefix = profile + ':keystone.'
    else:
        prefix = 'keystone.'

    def get(key, default=None):
        '''
        Checks connection_args, then salt-minion config,
        falls back to specified default value.
        '''
        return connection_args.get('connection_' + key,
                                   __salt__['config.get'](prefix + key, default))

    user = get('user', 'admin')
    password = get('password', None)
    tenant = get('tenant', 'admin')
    tenant_id = get('tenant_id')
    auth_url = get('auth_url', 'http://127.0.0.1:35357/v2.0')
    insecure = get('insecure', False)
    admin_token = get('token')
    region_name = get('region_name', None)

    if admin_token and api_version != 1 and not password:
        # If we had a password we could just
        # ignore the admin-token and move on...
        raise SaltInvocationError('Only can use keystone admin token ' +
                                  'with Heat API v1')
    elif password:
        # Can't use the admin-token anyway
        kwargs = {'username': user,
                  'password': password,
                  'tenant_id': tenant_id,
                  'auth_url': auth_url,
                  'region_name': region_name,
                  'tenant_name': tenant}
        # 'insecure' keyword not supported by all v2.0 keystone clients
        #   this ensures it's only passed in when defined
        if insecure:
            kwargs['insecure'] = True
    elif api_version == 1 and admin_token:
        kwargs = {'token': admin_token,
                  'auth_url': auth_url}
    else:
        raise SaltInvocationError('No credentials to authenticate with.')

    token = __salt__['keystone.token_get'](profile)
    kwargs['token'] = token['id']
    # This doesn't realy prevent the password to show up
    # in the minion log as keystoneclient.session is
    # logging it anyway when in debug-mode
    kwargs.pop('password')
    try:
        heat_endpoint = __salt__['keystone.endpoint_get']('heat', profile)['url']
    except KeyError:
        heat_endpoint = __salt__['keystone.endpoint_get']('heat', profile)['publicurl']
    heat_endpoint = heat_endpoint % token
    log.debug('Calling heatclient.client.Client(%s, %s, **%s)',
              api_version, heat_endpoint, kwargs)
    # may raise exc.HTTPUnauthorized, exc.HTTPNotFound
    # but we deal with those elsewhere
    return heatclient.client.Client(api_version, endpoint=heat_endpoint, **kwargs)


def _parse_template(tmpl_str):
    '''
    Parsing template
    '''
    tmpl_str = tmpl_str.strip()
    if tmpl_str.startswith('{'):
        tpl = salt.utils.json.loads(tmpl_str)
    else:
        try:
            tpl = salt.utils.yaml.safe_load(tmpl_str)
        except salt.utils.yaml.YAMLError as exc:
            raise ValueError(six.text_type(exc))
        else:
            if tpl is None:
                tpl = {}
    if not ('HeatTemplateFormatVersion' in tpl
            or 'heat_template_version' in tpl
            or 'AWSTemplateFormatVersion' in tpl):
        raise ValueError(('Template format version not found.'))
    return tpl


def _parse_environment(env_str):
    '''
    Parsing template
    '''
    try:
        env = salt.utils.yaml.safe_load(env_str)
    except salt.utils.yaml.YAMLError as exc:
        raise ValueError(six.text_type(exc))
    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 "{0}"'.format(param))

    return env


def _get_stack_events(h_client, stack_id, event_args):
    '''
    Get event for stack
    '''
    event_args['stack_id'] = stack_id
    event_args['resource_name'] = None
    try:
        events = h_client.events.list(**event_args)
    except heatclient.exc.HTTPNotFound as exc:
        raise heatclient.exc.CommandError(six.text_type(exc))
    else:
        for event in events:
            event.stack_name = stack_id.split('/')[0]
        return events


def _poll_for_events(h_client, stack_name, action=None, poll_period=5,
                     timeout=60, marker=None):
    '''
    Polling stack events
    '''
    if action:
        stop_status = ('{0}_FAILED'.format(action), '{0}_COMPLETE'.format(action))
        stop_check = lambda a: a in stop_status
    else:
        stop_check = lambda a: a.endswith('_COMPLETE') or a.endswith('_FAILED')
    timeout_sec = timeout * 60
    no_event_polls = 0
    msg_template = ('\n Stack %(name)s %(status)s \n')
    while True:
        events = _get_stack_events(h_client, stack_id=stack_name,
                                   event_args={'sort_dir': 'asc', 'marker': marker})

        if not events:
            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 getattr(event, 'resource_name', '') == stack_name:
                    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 = h_client.stacks.get(stack_name)
            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)
        timeout_sec -= poll_period
        if timeout_sec <= 0:
            stack_status = '{0}_FAILED'.format(action)
            msg = 'Timeout expired'
            return stack_status, msg


def list_stack(profile=None):
    '''
    Return a list of available stack (heat stack-list)

    profile
        Profile to use

    CLI Example:

    .. code-block:: bash

        salt '*' heat.list_stack profile=openstack1
    '''
    ret = {}
    h_client = _auth(profile)
    for stack in h_client.stacks.list():
        links = {}
        for link in stack.links:
            links[link['rel']] = link['href']
        ret[stack.stack_name] = {
            'status': stack.stack_status,
            'id': stack.id,
            'name': stack.stack_name,
            'creation': stack.creation_time,
            'owner': stack.stack_owner,
            'reason': stack.stack_status_reason,
            'links': links,
        }
    return ret


def show_stack(name=None, profile=None):
    '''
    Return details about a specific stack (heat stack-show)

    name
        Name of the stack

    profile
        Profile to use

    CLI Example:

    .. code-block:: bash

        salt '*' heat.show_stack name=mystack profile=openstack1
    '''
    h_client = _auth(profile)
    if not name:
        return {
            'result': False,
            'comment':
                'Parameter name missing or None'
            }
    try:
        ret = {}
        stack = h_client.stacks.get(name)
        links = {}
        for link in stack.links:
            links[link['rel']] = link['href']
        ret[stack.stack_name] = {
            'status': stack.stack_status,
            'id': stack.id,
            'name': stack.stack_name,
            'creation': stack.creation_time,
            'owner': stack.stack_owner,
            'reason': stack.stack_status_reason,
            'parameters': stack.parameters,
            'links': links,
        }
        ret['result'] = True
    except heatclient.exc.HTTPNotFound:
        return {
            'result': False,
            'comment': 'No stack {0}'.format(name)
            }
    return ret


def delete_stack(name=None, poll=0, timeout=60, profile=None):
    '''
    Delete a stack (heat stack-delete)

    name
        Name of the stack

    poll
        Poll and report events until stack complete

    timeout
        Stack creation timeout in minute

    profile
        Profile to use


    CLI Examples:

    .. code-block:: bash

        salt '*' heat.delete_stack name=mystack poll=5 \\
                 profile=openstack1
    '''
    h_client = _auth(profile)
    ret = {
        'result': True,
        'comment': ''
    }
    if not name:
        ret['result'] = False
        ret['comment'] = 'Parameter name missing or None'
        return ret
    try:
        h_client.stacks.delete(name)
    except heatclient.exc.HTTPNotFound:
        ret['result'] = False
        ret['comment'] = 'No stack {0}'.format(name)
    except heatclient.exc.HTTPForbidden as forbidden:
        log.exception(forbidden)
        ret['result'] = False
        ret['comment'] = six.text_type(forbidden)
    if ret['result'] is False:
        return ret

    if poll > 0:
        try:
            stack_status, msg = _poll_for_events(h_client, name, action='DELETE',
                                                 poll_period=poll, timeout=timeout)
        except heatclient.exc.CommandError:
            ret['comment'] = 'Deleted stack {0}.'.format(name)
            return ret
        except Exception as ex:  # pylint: disable=W0703
            log.exception('Delete failed %s', ex)
            ret['result'] = False
            ret['comment'] = '{0}'.format(ex)
            return ret

        if stack_status == 'DELETE_FAILED':
            ret['result'] = False
            ret['comment'] = 'Deleted stack FAILED\'{0}\'{1}.'.format(name, msg)
        else:
            ret['comment'] = 'Deleted stack {0}.'.format(name)
    return ret


def create_stack(name=None, template_file=None, environment=None,
                 parameters=None, poll=0, rollback=False, timeout=60,
                 profile=None, enviroment=None):
    '''
    Create a stack (heat stack-create)

    name
        Name of the new stack

    template_file
        File of template

    environment
        File of environment

    parameters
        Parameter dict used to create the stack

    poll
        Poll and report events until stack complete

    rollback
        Enable rollback on create failure

    timeout
        Stack creation timeout in minutes

    profile
        Profile to build on

    CLI Example:

    .. code-block:: bash

        salt '*' heat.create_stack name=mystack \\
                 template_file=salt://template.yaml \\
                 environment=salt://environment.yaml \\
                 parameters="{"image": "Debian 8", "flavor": "m1.small"}" \\
                 poll=5 rollback=False timeout=60 profile=openstack1

    .. versionadded:: 2017.7.5,2018.3.1

        The spelling mistake in parameter `enviroment` was corrected to `environment`.
        The misspelled version is still supported for backward compatibility, but will
        be removed in Salt Neon.

    '''
    if environment is None and enviroment is not None:
        salt.utils.versions.warn_until('Neon', (
            "Please use the 'environment' parameter instead of the misspelled 'enviroment' "
            "parameter which will be removed in Salt Neon."
        ))
        environment = enviroment
    h_client = _auth(profile)
    ret = {
        'result': True,
        'comment': ''
    }
    if not parameters:
        parameters = {}
    if template_file:
        template_tmp_file = salt.utils.files.mkstemp()
        tsfn, source_sum, comment_ = __salt__['file.get_managed'](
            name=template_tmp_file,
            template=None,
            source=template_file,
            source_hash=None,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            context=None,
            defaults=None,
            skip_verify=False,
            kwargs=None)

        template_manage_result = __salt__['file.manage_file'](
            name=template_tmp_file,
            sfn=tsfn,
            ret=None,
            source=template_file,
            source_sum=source_sum,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            backup=None,
            makedirs=True,
            template=None,
            show_changes=False,
            contents=None,
            dir_mode=None)
        if template_manage_result['result']:
            with salt.utils.files.fopen(template_tmp_file, 'r') as tfp_:
                tpl = salt.utils.stringutils.to_unicode(tfp_.read())
                salt.utils.files.safe_rm(template_tmp_file)
                try:
                    template = _parse_template(tpl)
                except ValueError as ex:
                    ret['result'] = False
                    ret['comment'] = 'Error parsing template {0}'.format(ex)
        else:
            ret['result'] = False
            ret['comment'] = 'Can not open template: {0} {1}'.format(template_file, comment_)
    else:
        ret['result'] = False
        ret['comment'] = 'Can not open template'
    if ret['result'] is False:
        return ret

    kwargs = {}
    kwargs['template'] = template
    try:
        h_client.stacks.validate(**kwargs)
    except Exception as ex:
        log.exception('Template not valid')
        ret['result'] = False
        ret['comment'] = 'Template not valid: {0}'.format(ex)
        return ret
    env = {}
    if environment:
        environment_tmp_file = salt.utils.files.mkstemp()
        esfn, source_sum, comment_ = __salt__['file.get_managed'](
            name=environment_tmp_file,
            template=None,
            source=environment,
            source_hash=None,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            context=None,
            defaults=None,
            skip_verify=False,
            kwargs=None)

        environment_manage_result = __salt__['file.manage_file'](
            name=environment_tmp_file,
            sfn=esfn,
            ret=None,
            source=environment,
            source_sum=source_sum,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            backup=None,
            makedirs=True,
            template=None,
            show_changes=False,
            contents=None,
            dir_mode=None)
        if environment_manage_result['result']:
            with salt.utils.files.fopen(environment_tmp_file, 'r') as efp_:
                env_str = salt.utils.stringutils.to_unicode(efp_.read())
                salt.utils.files.safe_rm(environment_tmp_file)
                try:
                    env = _parse_environment(env_str)
                except ValueError as ex:
                    ret['result'] = False
                    ret['comment'] = 'Error parsing template {0}'.format(ex)
        else:
            ret['result'] = False
            ret['comment'] = 'Can not open environment: {0}, {1}'.format(environment, comment_)
    if ret['result'] is False:
        return ret

    fields = {
        'stack_name': name,
        'disable_rollback': not rollback,
        'parameters': parameters,
        'template': template,
        'environment': env,
        'timeout_mins': timeout
    }

    # If one or more environments is found, pass the listing to the server
    try:
        h_client.stacks.create(**fields)
    except Exception as ex:  # pylint: disable=W0703
        log.exception('Create failed')
        ret['result'] = False
        ret['comment'] = six.text_type(ex)
        return ret
    if poll > 0:
        stack_status, msg = _poll_for_events(h_client, name, action='CREATE',
                                             poll_period=poll, timeout=timeout)
        if stack_status == 'CREATE_FAILED':
            ret['result'] = False
            ret['comment'] = 'Created stack FAILED\'{0}\'{1}.'.format(name, msg)
    if ret['result'] is True:
        ret['comment'] = 'Created stack \'{0}\'.'.format(name)
    return ret


def update_stack(name=None, template_file=None, environment=None,
                 parameters=None, poll=0, rollback=False, timeout=60,
                 profile=None, enviroment=None):
    '''
    Update a stack (heat stack-template)

    name
        Name of the  stack

    template_file
        File of template

    environment
        File of environment

    parameters
        Parameter dict used to update the stack

    poll
        Poll and report events until stack complete

    rollback
        Enable rollback on update failure

    timeout
        Stack creation timeout in minutes

    profile
        Profile to build on

    CLI Example:

    .. code-block:: bash

        salt '*' heat.update_stack name=mystack \\
                 template_file=salt://template.yaml \\
                 environment=salt://environment.yaml \\
                 parameters="{"image": "Debian 8", "flavor": "m1.small"}" \\
                 poll=5 rollback=False timeout=60 profile=openstack1

    .. versionadded:: 2017.7.5,2018.3.1

        The spelling mistake in parameter `enviroment` was corrected to `environment`.
        The misspelled version is still supported for backward compatibility, but will
        be removed in Salt Neon.

    '''
    if environment is None and enviroment is not None:
        salt.utils.versions.warn_until('Neon', (
            "Please use the 'environment' parameter instead of the misspelled 'enviroment' "
            "parameter which will be removed in Salt Neon."
        ))
        environment = enviroment
    h_client = _auth(profile)
    ret = {
        'result': True,
        'comment': ''
    }
    if not name:
        ret['result'] = False
        ret['comment'] = 'Parameter name missing or None'
        return ret
    if not parameters:
        parameters = {}
    if template_file:
        template_tmp_file = salt.utils.files.mkstemp()
        tsfn, source_sum, comment_ = __salt__['file.get_managed'](
            name=template_tmp_file,
            template=None,
            source=template_file,
            source_hash=None,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            context=None,
            defaults=None,
            skip_verify=False,
            kwargs=None)

        template_manage_result = __salt__['file.manage_file'](
            name=template_tmp_file,
            sfn=tsfn,
            ret=None,
            source=template_file,
            source_sum=source_sum,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            backup=None,
            makedirs=True,
            template=None,
            show_changes=False,
            contents=None,
            dir_mode=None)
        if template_manage_result['result']:
            with salt.utils.files.fopen(template_tmp_file, 'r') as tfp_:
                tpl = salt.utils.stringutils.to_unicode(tfp_.read())
                salt.utils.files.safe_rm(template_tmp_file)
                try:
                    template = _parse_template(tpl)
                except ValueError as ex:
                    ret['result'] = False
                    ret['comment'] = 'Error parsing template {0}'.format(ex)
        else:
            ret['result'] = False
            ret['comment'] = 'Can not open template: {0} {1}'.format(template_file, comment_)
    else:
        ret['result'] = False
        ret['comment'] = 'Can not open template'
    if ret['result'] is False:
        return ret

    kwargs = {}
    kwargs['template'] = template
    try:
        h_client.stacks.validate(**kwargs)
    except Exception as ex:  # pylint: disable=W0703
        log.exception('Template not valid')
        ret['result'] = False
        ret['comment'] = 'Template not valid {0}'.format(ex)
        return ret
    env = {}
    if environment:
        environment_tmp_file = salt.utils.files.mkstemp()
        esfn, source_sum, comment_ = __salt__['file.get_managed'](
            name=environment_tmp_file,
            template=None,
            source=environment,
            source_hash=None,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            context=None,
            defaults=None,
            skip_verify=False,
            kwargs=None)

        environment_manage_result = __salt__['file.manage_file'](
            name=environment_tmp_file,
            sfn=esfn,
            ret=None,
            source=environment,
            source_sum=source_sum,
            user=None,
            group=None,
            mode=None,
            saltenv='base',
            backup=None,
            makedirs=True,
            template=None,
            show_changes=False,
            contents=None,
            dir_mode=None)
        if environment_manage_result['result']:
            with salt.utils.files.fopen(environment_tmp_file, 'r') as efp_:
                env_str = salt.utils.stringutils.to_unicode(efp_.read())
                salt.utils.files.safe_rm(environment_tmp_file)
                try:
                    env = _parse_environment(env_str)
                except ValueError as ex:
                    ret['result'] = False
                    ret['comment'] = 'Error parsing template {0}'.format(ex)
        else:
            ret['result'] = False
            ret['comment'] = 'Can not open environment: {0}, {1}'.format(environment, comment_)
    if ret['result'] is False:
        return ret

    fields = {
        'disable_rollback': not rollback,
        'parameters': parameters,
        'template': template,
        'environment': env,
        'timeout_mins': timeout
    }

    try:
        h_client.stacks.update(name, **fields)
    except Exception as ex:  # pylint: disable=W0703
        log.exception('Update failed')
        ret['result'] = False
        ret['comment'] = 'Update failed {0}'.format(ex)
        return ret

    if poll > 0:
        stack_status, msg = _poll_for_events(h_client, name, action='UPDATE',
                                             poll_period=poll, timeout=timeout)
        if stack_status == 'UPDATE_FAILED':
            ret['result'] = False
            ret['comment'] = 'Updated stack FAILED\'{0}\'{1}.'.format(name, msg)
    if ret['result'] is True:
        ret['comment'] = 'Updated stack \'{0}\'.'.format(name),
    return ret


def template_stack(name=None, profile=None):
    '''
    Return template a specific stack (heat stack-template)

    name
        Name of the stack

    profile
        Profile to use

    CLI Example:

    .. code-block:: bash

        salt '*' heat.template_stack name=mystack profile=openstack1
    '''
    h_client = _auth(profile)

    if not name:
        return {
            'result': False,
            'comment':
                'Parameter name missing or None'
            }
    try:
        get_template = h_client.stacks.template(name)
    except heatclient.exc.HTTPNotFound:
        return {
            'result': False,
            'comment': 'No stack with {0}'.format(name)
            }
    except heatclient.exc.BadRequest:
        return {
            'result': False,
            'comment': 'Bad request fot stack {0}'.format(name)
            }
    if 'heat_template_version' in get_template:
        template = salt.utils.yaml.safe_dump(get_template)
    else:
        template = jsonutils.dumps(get_template, indent=2, ensure_ascii=False)

    checksum = __salt__['hashutil.digest'](template)
    ret = {
        'template': template,
        'result': True,
        'checksum': checksum
    }
    return ret