Create a state and a module to create images using import tasks

In Glance V2 it is possible to import an image from a remote
location only using a tasks workflow. [1] As tasks are not
supported in salt.modules.glance I had to create a new module
glanceng that extends functionality of the glance module.
That is supposed to be a temporary solution until we move to a
SaltStack version that has support of Glance V2 tasks.

Added the `get_image_owner_id` function to the glanceng module
that is used for mining owner of a created image in Octavia
stare.

Added a new state image_import to _states/glanceng that creates
a task with type `import` and the verifies that the task succeeded
and an image was created.

Updated glance/client.sls to use image_import state and extended
the list of parameters that can be passed to that state.

[1] https://wiki.openstack.org/wiki/Glance-tasks-import

Change-Id: Ica8d02ed4a1653a74ac87ff5ee0efbe5d19feed0
diff --git a/_modules/glanceng.py b/_modules/glanceng.py
new file mode 100644
index 0000000..0de0a85
--- /dev/null
+++ b/_modules/glanceng.py
@@ -0,0 +1,378 @@
+# -*- coding: utf-8 -*-
+"""
+Module extending the salt.modules.glance modules.
+
+This module adds functionality for managing Glance V2 tasks by exposing the
+following functions:
+  - task_create
+  - task_show
+  - task_list
+
+:optdepends:    - glanceclient Python adapter
+:configuration: This module is not usable until the following are specified
+    either in a pillar or in the minion's config file::
+
+        keystone.user: admin
+        keystone.password: verybadpass
+        keystone.tenant: admin
+        keystone.insecure: False   #(optional)
+        keystone.auth_url: 'http://127.0.0.1:5000/v2.0/'
+
+    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 glance functions can
+    make use of a configuration profile by declaring it explicitly.
+    For example::
+
+        salt '*' glance.image_list profile=openstack1
+"""
+
+# Import Python libs
+from __future__ import absolute_import
+import logging
+import pprint
+import re
+
+# Import salt libs
+from salt.exceptions import SaltInvocationError
+
+from salt.version import (
+    __version__,
+    SaltStackVersion
+    )
+# is there not SaltStackVersion.current() to get
+# the version of the salt running this code??
+_version_ary = __version__.split('.')
+CUR_VER = SaltStackVersion(_version_ary[0], _version_ary[1])
+BORON = SaltStackVersion.from_name('Boron')
+
+# pylint: disable=import-error
+HAS_GLANCE = False
+try:
+    from glanceclient import client
+    from glanceclient import exc
+    HAS_GLANCE = True
+except ImportError:
+    pass
+
+# Workaround, as the Glance API v2 requires you to
+# already have a keystone session token
+HAS_KEYSTONE = False
+try:
+    from keystoneclient.v2_0 import client as kstone
+    HAS_KEYSTONE = True
+except ImportError:
+    pass
+
+
+logging.basicConfig(level=logging.DEBUG)
+log = logging.getLogger(__name__)
+
+
+def __virtual__():
+    '''
+    Only load this module if glance
+    is installed on this minion.
+    '''
+    if not HAS_GLANCE:
+        return False, ("The glance execution module cannot be loaded: "
+                       "the glanceclient python library is not available.")
+    if not HAS_KEYSTONE:
+        return False, ("The keystone execution module cannot be loaded: "
+                       "the keystoneclient python library is not available.")
+    return True
+
+
+__opts__ = {}
+
+
+def _auth(profile=None, api_version=2, **connection_args):
+    '''
+    Set up glance credentials, returns
+    `glanceclient.client.Client`. Optional parameter
+    "api_version" defaults to 2.
+
+    Only intended to be used within glance-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 = get('region')
+    ks_endpoint = get('endpoint', 'http://127.0.0.1:9292/')
+    g_endpoint_url = __salt__['keystone.endpoint_get']('glance', profile)
+    # The trailing 'v2' causes URLs like thise one:
+    # http://127.0.0.1:9292/v2/v1/images
+    g_endpoint_url = re.sub('/v2', '', g_endpoint_url['internalurl'])
+
+    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 Glance API v1')
+    elif password:
+        # Can't use the admin-token anyway
+        kwargs = {'username': user,
+                  'password': password,
+                  'tenant_id': tenant_id,
+                  'auth_url': auth_url,
+                  'endpoint_url': g_endpoint_url,
+                  'region_name': region,
+                  '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,
+                  'endpoint_url': g_endpoint_url}
+    else:
+        raise SaltInvocationError('No credentials to authenticate with.')
+
+    if HAS_KEYSTONE:
+        log.debug('Calling keystoneclient.v2_0.client.Client(' +
+            '{0}, **{1})'.format(ks_endpoint, kwargs))
+        keystone = kstone.Client(**kwargs)
+        kwargs['token'] = keystone.get_token(keystone.session)
+        # 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')
+        log.debug('Calling glanceclient.client.Client(' +
+            '{0}, {1}, **{2})'.format(api_version,
+                g_endpoint_url, kwargs))
+        # may raise exc.HTTPUnauthorized, exc.HTTPNotFound
+        # but we deal with those elsewhere
+        return client.Client(api_version, g_endpoint_url, **kwargs)
+    else:
+        raise NotImplementedError(
+            "Can't retrieve a auth_token without keystone")
+
+
+def _validate_image_params(visibility=None, container_format='bare',
+                           disk_format='raw', tags=None, **kwargs):
+    # valid options for "visibility":
+    v_list = ['public', 'private', 'shared', 'community']
+    # valid options for "container_format":
+    cf_list = ['ami', 'ari', 'aki', 'bare', 'ovf']
+    # valid options for "disk_format":
+    df_list = ['ami', 'ari', 'aki', 'vhd', 'vmdk',
+               'raw', 'qcow2', 'vdi', 'iso']
+
+    if visibility is not None:
+        if visibility not in v_list:
+            raise SaltInvocationError('"visibility" needs to be one ' +
+                                      'of the following: {0}'.format(
+                                          ', '.join(v_list)))
+    if container_format not in cf_list:
+        raise SaltInvocationError('"container_format" needs to be ' +
+                                  'one of the following: {0}'.format(
+                                      ', '.join(cf_list)))
+    if disk_format not in df_list:
+        raise SaltInvocationError('"disk_format" needs to be one ' +
+                                  'of the following: {0}'.format(
+                                      ', '.join(df_list)))
+    if tags:
+        if not isinstance(tags, list):
+            raise SaltInvocationError('Incorrect input type for the {0} '
+                                      'parameter: expected: {1}, '
+                                      'got {2}'.format("tags", list,
+                                                       type(tags)))
+
+
+def _validate_task_params(task_type, input_params):
+    # Only import tasks are currently supported
+    # TODO(eezhova): Add support for "export" and "clone" task types
+    valid_task_types = ["import", ]
+
+    import_required_params = {"import_from", "import_from_format",
+                              "image_properties"}
+
+    if task_type not in valid_task_types:
+        raise SaltInvocationError("'task_type' must be one of the following: "
+                                  "{0}".format(', '.join(valid_task_types)))
+
+    if task_type == "import":
+        valid_import_from_formats = ['ami', 'ari', 'aki', 'vhd', 'vmdk',
+                                     'raw', 'qcow2', 'vdi', 'iso']
+        missing_params = import_required_params - set(input_params.keys())
+        if missing_params:
+            raise SaltInvocationError(
+                "Missing the following task parameters for the 'import' task: "
+                "{0}".format(', '.join(missing_params)))
+
+        import_from = input_params['import_from']
+        import_from_format = input_params['import_from_format']
+        image_properties = input_params['image_properties']
+        if not import_from.startswith(('http://', 'https://')):
+            raise SaltInvocationError("Only non-local sources of image data "
+                                      "are supported.")
+        if import_from_format not in valid_import_from_formats:
+            raise SaltInvocationError(
+                "'import_from_format' needs to be one of the following: "
+                "{0}".format(', '.join(valid_import_from_formats)))
+        _validate_image_params(**image_properties)
+
+
+def task_create(task_type, profile=None, input_params=None):
+    """
+    Create a Glance V2 task of a given type
+
+    :param task_type: Task type
+    :param profile: Authentication profile
+    :param input_params: Dictionary with input parameters for a task
+    :return: Dictionary with created task's parameters
+    """
+    g_client = _auth(profile, api_version=2)
+    log.debug(
+        'Task type: {}\nInput params: {}'.format(task_type, input_params)
+    )
+    task = g_client.tasks.create(type=task_type, input=input_params)
+    log.debug("Created task: {}".format(dict(task)))
+    created_task = task_show(task.id, profile=profile)
+    return created_task
+
+
+def task_show(task_id, profile=None):
+    """
+    Show a Glance V2 task
+
+    :param task_id: ID of a task to show
+    :param profile: Authentication profile
+    :return: Dictionary with created task's parameters
+    """
+    g_client = _auth(profile)
+    ret = {}
+    try:
+        task = g_client.tasks.get(task_id)
+    except exc.HTTPNotFound:
+        return {
+            'result': False,
+            'comment': 'No task with ID {0}'.format(task_id)
+        }
+    pformat = pprint.PrettyPrinter(indent=4).pformat
+    log.debug('Properties of task {0}:\n{1}'.format(
+        task_id, pformat(task)))
+
+    schema = image_schema(schema_type='task', profile=profile)
+    if len(schema.keys()) == 1:
+        schema = schema['task']
+    for key in schema.keys():
+        if key in task:
+            ret[key] = task[key]
+    return ret
+
+
+def task_list(profile=None):
+    """
+    List Glance V2 tasks
+
+    :param profile: Authentication profile
+    :return: Dictionary with existing tasks
+    """
+    g_client = _auth(profile)
+    ret = {}
+    tasks = g_client.tasks.list()
+    schema = image_schema(schema_type='task', profile=profile)
+    if len(schema.keys()) == 1:
+        schema = schema['task']
+    for task in tasks:
+        task_dict = {}
+        for key in schema.keys():
+            if key in task:
+                task_dict[key] = task[key]
+        ret[task['id']] = task_dict
+    return ret
+
+
+def get_image_owner_id(name, profile=None):
+    """
+    Mine function to get image owner
+
+    :param name: Name of the image
+    :param profile: Authentication profile
+    :return: Image owner ID or [] if image is not found
+    """
+    g_client = _auth(profile)
+    image_id = None
+    for image in g_client.images.list():
+        if image.name == name:
+            image_id = image.id
+            continue
+    if not image_id:
+        return []
+    try:
+        image = g_client.images.get(image_id)
+    except exc.HTTPNotFound:
+        return []
+    return image['owner']
+
+
+def image_schema(schema_type='image', profile=None):
+    '''
+    Returns names and descriptions of the schema "image"'s
+    properties for this profile's instance of glance
+
+    CLI Example:
+
+    .. code-block:: bash
+
+        salt '*' glance.image_schema
+    '''
+    return schema_get(schema_type, profile)
+
+
+def schema_get(name, profile=None):
+    '''
+    Known valid names of schemas are:
+      - image
+      - images
+      - member
+      - members
+
+    CLI Example:
+
+    .. code-block:: bash
+
+        salt '*' glance.schema_get name=f16-jeos
+    '''
+    g_client = _auth(profile)
+    pformat = pprint.PrettyPrinter(indent=4).pformat
+    schema_props = {}
+    for prop in g_client.schemas.get(name).properties:
+        schema_props[prop.name] = prop.description
+    log.debug('Properties of schema {0}:\n{1}'.format(
+        name, pformat(schema_props)))
+    return {name: schema_props}
diff --git a/_states/glanceng.py b/_states/glanceng.py
index fb41fb1..e245866 100644
--- a/_states/glanceng.py
+++ b/_states/glanceng.py
@@ -8,8 +8,6 @@
 import logging
 import time
 
-# Import salt libs
-
 # Import OpenStack libs
 try:
     from keystoneclient.exceptions import \
@@ -246,4 +244,161 @@
             ret['comment'] += 'Checksum won\'t be verified as image ' +\
                 'hasn\'t reached\n\t "status=active" yet.\n'
     log.debug('glance.image_present will return: {0}'.format(ret))
-    return ret
\ No newline at end of file
+    return ret
+
+
+def image_import(name, profile=None, visibility='public', protected=False,
+                 location=None, import_from_format='raw', disk_format='raw',
+                 container_format='bare', tags=None,
+                 checksum=None, timeout=30):
+    """
+    Creates a task to import an image
+
+    This state checks if an image is present and, if not, creates a task
+    with import_type that would download an image from a remote location and
+    upload it to Glance.
+    After the task is created, its status is monitored. On success the state
+    would check that an image is present and return its ID.
+
+    *Important*: This state is supposed to work only with Glance V2 API as
+                 opposed to the image_present state that is compatible only
+                 with Glance V1.
+
+    :param name: Name of an image
+    :param profile: Authentication profile
+    :param visibility: Scope of image accessibility.
+                       Valid values: public, private, community, shared
+    :param protected: If true, image will not be deletable.
+    :param location: a URL where Glance can get the image data
+    :param import_from_format: Format to import the image from
+    :param disk_format: Format of the disk
+    :param container_format: Format of the container
+    :param tags: List of strings related to the image
+    :param checksum: Checksum of the image to import, it would be used to
+                     validate the checksum of a newly created image
+    :param timeout: Time to wait for an import task to succeed
+    """
+
+    ret = {'name': name,
+           'changes': {},
+           'result': True,
+           'comment': 'Image "{0}" already exists'.format(name)}
+    tags = tags or []
+
+    image, msg = _find_image(name, profile)
+    log.debug(msg)
+    if image:
+        return ret
+    elif image is False:
+        if __opts__['test']:
+            ret['result'] = None
+        else:
+            ret['result'] = False
+        ret['comment'] = msg
+        return ret
+    else:
+        if __opts__['test']:
+            ret['result'] = None
+            ret['comment'] = ("glanceng.image_import would create an image "
+                              "from {0}".format(location))
+            return ret
+
+        image_properties = {"container_format": container_format,
+                            "disk_format": disk_format,
+                            "name": name,
+                            "protected": protected,
+                            "tags": tags,
+                            "visibility": visibility
+                            }
+        task_params = {"import_from": location,
+                       "import_from_format": import_from_format,
+                       "image_properties": image_properties
+                       }
+
+        task = __salt__['glanceng.task_create'](
+            task_type='import', profile=profile,
+            input_params=task_params)
+        task_id = task['id']
+        log.debug('Created new task:\n{0}'.format(task))
+        ret['changes'] = {
+            name:
+                {
+                    'new':
+                        {
+                            'task_id': task_id
+                        },
+                    'old': None
+                }
+            }
+
+        # Wait for the task to complete
+        timer = timeout
+        while timer > 0:
+            if 'status' in task and task['status'] == 'success':
+                log.debug('Task {0} has successfully completed'.format(
+                    task_id))
+                break
+            elif 'status' in task and task['status'] == 'failure':
+                msg = "Task {0} has failed".format(task_id)
+                ret['result'] = False
+                ret['comment'] = msg
+                return ret
+            else:
+                timer -= 5
+                time.sleep(5)
+                existing_tasks = __salt__['glanceng.task_list'](profile)
+                if task_id not in existing_tasks:
+                    ret['result'] = False
+                    ret['comment'] += 'Created task {0} '.format(
+                        task_id) + ' vanished:\n' + msg
+                    return ret
+                else:
+                    task = existing_tasks[task_id]
+        if timer <= 0 and task['status'] != 'success':
+            ret['result'] = False
+            ret['comment'] = ('Task {0} did not reach state success before '
+                              'the timeout:\nLast status was '
+                              '"{1}".\n'.format(task_id, task['status']))
+            return ret
+
+        # The import task has successfully completed. Now, let's check that it
+        # created the image.
+        image, msg = _find_image(name, profile)
+        if not image:
+            ret['result'] = False
+            ret['comment'] = msg
+        else:
+            ret['changes'][name]['new']['image_id'] = image['id']
+            ret['changes'][name]['new']['image_status'] = image['status']
+            ret['comment'] = ("Image {0} was successfully created by task "
+                              "{1}".format(image['id'], task_id))
+            if checksum:
+                if image['status'] == 'active':
+                    if 'checksum' not in image:
+                        # Refresh our info about the image
+                        image = __salt__['glance.image_show'](image['id'])
+                    if 'checksum' not in image:
+                        if not __opts__['test']:
+                            ret['result'] = False
+                        else:
+                            ret['result'] = None
+                        ret['comment'] += (
+                            "No checksum available for this image:\n"
+                            "Image has status '{0}'.".format(image['status']))
+                    elif image['checksum'] != checksum:
+                        if not __opts__['test']:
+                            ret['result'] = False
+                        else:
+                            ret['result'] = None
+                        ret['comment'] += ("'checksum' is {0}, should be "
+                                           "{1}.\n".format(image['checksum'],
+                                                           checksum))
+                    else:
+                        ret['comment'] += (
+                            "'checksum' is correct ({0}).\n".format(checksum))
+                elif image['status'] in ['saving', 'queued']:
+                    ret['comment'] += (
+                        "Checksum will not be verified as image has not "
+                        "reached 'status=active' yet.\n")
+        log.debug('glance.image_present will return: {0}'.format(ret))
+        return ret
diff --git a/glance/client.sls b/glance/client.sls
index c3f9213..3f45edb 100644
--- a/glance/client.sls
+++ b/glance/client.sls
@@ -10,9 +10,12 @@
 {%- for image_name, image in identity.image.iteritems() %}
 
 glance_openstack_image_{{ image_name }}:
-  glanceng.image_present:
-    - name: {{ image_name }}
+  glanceng.image_import:
+    - name: {{ image.get('name', image_name) }}
     - profile: {{ identity_name }}
+    {%- if image.import_from_format is defined %}
+    - import_from_format: {{ image.import_from_format }}
+    {%- endif %}
     {%- if image.visibility is defined %}
     - visibility: {{ image.visibility }}
     {%- endif %}
@@ -22,6 +25,21 @@
     {%- if image.location is defined %}
     - location: {{ image.location }}
     {%- endif %}
+    {%- if image.tags is defined %}
+    - tags: {{ image.tags }}
+    {%- endif %}
+    {%- if image.disk_format is defined %}
+    - disk_format: {{ image.disk_format }}
+    {%- endif %}
+    {%- if image.container_format is defined %}
+    - container_format: {{ image.container_format }}
+    {%- endif %}
+    {%- if image.wait_timeout is defined %}
+    - timeout: {{ image.wait_timeout }}
+    {%- endif %}
+    {%- if image.checksum is defined %}
+    - checksum: {{ image.checksum }}
+    {%- endif %}
 
 {%- endfor %}
 {%- endfor %}