Adds Images API tests

* Adds new images API tests in tempest/tests/images
* Adds tempest.openstack.ServiceManager class to deal with non-Compute API
  clients
* Adds an [image] section to the configuration file and manager
  that deals with Image API stuff
* Updates the tools/conf_from_devstack script to write an image
  section to the generated tempest.conf

This is all in preparation for removing the functional integration
tests from Glance and putting them in Tempest...

Change-Id: I6caf50e5cab97794204472151acc88fcdd0fc224
diff --git a/tempest/config.py b/tempest/config.py
index 0d5a096..6f816d2 100644
--- a/tempest/config.py
+++ b/tempest/config.py
@@ -136,6 +136,62 @@
         return self.get("authentication", 'keystone')
 
 
+class ImagesConfig(object):
+    """
+    Provides configuration information for connecting to an
+    OpenStack Images service.
+    """
+
+    def __init__(self, conf):
+        self.conf = conf
+
+    def get(self, item_name, default_value=None):
+        try:
+            return self.conf.get("image", item_name)
+        except (ConfigParser.NoSectionError, ConfigParser.NoOptionError):
+            return default_value
+
+    @property
+    def host(self):
+        """Host IP for making Images API requests. Defaults to '127.0.0.1'."""
+        return self.get("host", "127.0.0.1")
+
+    @property
+    def port(self):
+        """Listen port of the Images service."""
+        return int(self.get("port", "9292"))
+
+    @property
+    def api_version(self):
+        """Version of the API"""
+        return self.get("api_version", "1")
+
+    @property
+    def username(self):
+        """Username to use for Images API requests. Defaults to 'admin'."""
+        return self.get("user", "admin")
+
+    @property
+    def password(self):
+        """Password for user"""
+        return self.get("password", "")
+
+    @property
+    def tenant(self):
+        """Tenant to use for Images API requests. Defaults to 'admin'."""
+        return self.get("tenant", "admin")
+
+    @property
+    def service_token(self):
+        """Token to use in querying the API. Default: None"""
+        return self.get("service_token")
+
+    @property
+    def auth_url(self):
+        """Optional URL to auth service. Will be discovered if None"""
+        return self.get("auth_url")
+
+
 class TempestConfig(object):
     """Provides OpenStack configuration information."""
 
@@ -165,6 +221,7 @@
         self._conf = self.load_config(path)
         self.nova = NovaConfig(self._conf)
         self.env = EnvironmentConfig(self._conf)
+        self.images = ImagesConfig(self._conf)
 
     def load_config(self, path):
         """Read configuration from given path and return a config object."""
diff --git a/tempest/openstack.py b/tempest/openstack.py
index af7ee84..ba32487 100644
--- a/tempest/openstack.py
+++ b/tempest/openstack.py
@@ -1,3 +1,5 @@
+import tempest.config
+from tempest.services.image import service as image_service
 from tempest.services.nova.json.images_client import ImagesClient
 from tempest.services.nova.json.flavors_client import FlavorsClient
 from tempest.services.nova.json.servers_client import ServersClient
@@ -74,3 +76,16 @@
                                               self.config.nova.username,
                                               self.config.nova.api_key,
                                               self.config.nova.auth_url)
+
+
+class ServiceManager(object):
+
+    """
+    Top-level object housing clients for OpenStack APIs
+    """
+
+    def __init__(self):
+        self.config = tempest.config.TempestConfig()
+        self.services = {}
+        self.services['image'] = image_service.Service(self.config)
+        self.images = self.services['image']
diff --git a/tempest/services/__init__.py b/tempest/services/__init__.py
index e69de29..b2fdc5f 100644
--- a/tempest/services/__init__.py
+++ b/tempest/services/__init__.py
@@ -0,0 +1,39 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack, LLC
+# 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.
+
+"""
+Base Service class, which acts as a descriptor for an OpenStack service
+in the test environment
+"""
+
+
+class Service(object):
+
+    def __init__(self, config):
+        """
+        Initializes the service.
+
+        :param config: `tempest.config.Config` object
+        """
+        self.config = config
+
+    def get_client(self):
+        """
+        Returns a client object that may be used to query
+        the service API.
+        """
+        raise NotImplementedError
diff --git a/tempest/services/image/__init__.py b/tempest/services/image/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/services/image/__init__.py
diff --git a/tempest/services/image/service.py b/tempest/services/image/service.py
new file mode 100644
index 0000000..efeacb3
--- /dev/null
+++ b/tempest/services/image/service.py
@@ -0,0 +1,62 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack, LLC
+# 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.
+
+"""
+Image Service class, which acts as a descriptor for the OpenStack Images
+service running in the test environment.
+"""
+
+from tempest.services import Service as BaseService
+
+
+class Service(BaseService):
+
+    def __init__(self, config):
+        """
+        Initializes the service.
+
+        :param config: `tempest.config.Config` object
+        """
+        self.config = config
+
+        # Determine the Images API version
+        self.api_version = int(config.images.api_version)
+
+        if self.api_version == 1:
+            # We load the client class specific to the API version...
+            from glance import client
+            creds = {
+                'username': config.images.username,
+                'password': config.images.password,
+                'tenant': config.images.tenant,
+                'auth_url': config.images.auth_url,
+                'strategy': 'keystone'
+            }
+            service_token = config.images.service_token
+            self._client = client.Client(config.images.host,
+                                         config.images.port,
+                                         auth_tok=service_token)
+        else:
+            raise NotImplementedError
+
+    def get_client(self):
+        """
+        Returns a client object that may be used to query
+        the service API.
+        """
+        assert self._client
+        return self._client
diff --git a/tempest/tests/image/__init__.py b/tempest/tests/image/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tempest/tests/image/__init__.py
diff --git a/tempest/tests/image/test_images.py b/tempest/tests/image/test_images.py
new file mode 100644
index 0000000..8567b51
--- /dev/null
+++ b/tempest/tests/image/test_images.py
@@ -0,0 +1,202 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 OpenStack, LLC
+# 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 cStringIO as StringIO
+import random
+
+import unittest2 as unittest
+
+from nose.plugins.attrib import attr
+from nose.plugins.skip import SkipTest
+
+GLANCE_INSTALLED = False
+try:
+    from glance import client
+    from glance.common import exception
+    GLANCE_INSTALLED = True
+except ImportError:
+    pass
+
+from tempest import openstack
+
+
+@unittest.skipUnless(GLANCE_INSTALLED, 'Glance not installed')
+class CreateRegisterImagesTest(unittest.TestCase):
+
+    """
+    Here we test the registration and creation of images
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.os = openstack.ServiceManager()
+        cls.client = cls.os.images.get_client()
+        cls.created_images = []
+
+    @classmethod
+    def tearDownClass(cls):
+        for image_id in cls.created_images:
+            cls.client.delete_image(image_id)
+
+    @attr(type='image')
+    def test_register_with_invalid_data(self):
+        """Negative tests for invalid data supplied to POST /images"""
+
+        metas = [
+            {
+                'id': '1'
+            },  # Cannot specify ID in registration
+            {
+                'container_format': 'wrong',
+            },  # Invalid container format
+            {
+                'disk_format': 'wrong',
+            },  # Invalid disk format
+        ]
+        for meta in metas:
+            try:
+                results = self.client.add_image(meta)
+            except exception.Invalid:
+                continue
+            self.fail("Did not raise Invalid for meta %s. Got results: %s" %
+                      (meta, results))
+
+    @attr(type='image')
+    def test_register_then_upload(self):
+        """Register, then upload an image"""
+        meta = {
+            'name': 'New Name',
+            'is_public': True,
+            'disk_format': 'vhd',
+            'container_format': 'bare',
+            'properties': {'prop1': 'val1'}
+        }
+        results = self.client.add_image(meta)
+        self.assertTrue('id' in results)
+        image_id = results['id']
+        self.created_images.append(image_id)
+        self.assertTrue('name' in results)
+        self.assertEqual(meta['name'], results['name'])
+        self.assertTrue('is_public' in results)
+        self.assertEqual(meta['is_public'], results['is_public'])
+        self.assertTrue('status' in results)
+        self.assertEqual('queued', results['status'])
+        self.assertTrue('properties' in results)
+        for key, val in meta['properties'].items():
+            self.assertEqual(val, results['properties'][key])
+
+        # Now try uploading an image file
+        image_file = StringIO.StringIO('*' * 1024)
+        results = self.client.update_image(image_id, image_data=image_file)
+        self.assertTrue('status' in results)
+        self.assertEqual('active', results['status'])
+        self.assertTrue('size' in results)
+        self.assertEqual(1024, results['size'])
+
+    @attr(type='image')
+    @unittest.skip('Skipping until Glance Bug 912897 is fixed')
+    def test_register_remote_image(self):
+        """Register a new remote image"""
+        meta = {
+            'name': 'New Remote Image',
+            'is_public': True,
+            'disk_format': 'raw',
+            'container_format': 'bare',
+            'location': 'http://example.com/someimage.iso'
+        }
+        results = self.client.add_image(meta)
+        self.assertTrue('id' in results)
+        image_id = results['id']
+        self.created_images.append(image_id)
+        self.assertTrue('name' in results)
+        self.assertEqual(meta['name'], results['name'])
+        self.assertTrue('is_public' in results)
+        self.assertEqual(meta['is_public'], results['is_public'])
+        self.assertTrue('status' in results)
+        self.assertEqual('active', results['status'])
+
+
+class ListImagesTest(unittest.TestCase):
+
+    """
+    Here we test the listing of image information
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        raise SkipTest('Skipping until Glance Bug 912897 is fixed')
+        cls.os = openstack.ServiceManager()
+        cls.client = cls.os.images.get_client()
+        cls.created_images = []
+        cls.original_images = cls.client.get_images()
+
+        # We add a few images here to test the listing functionality of
+        # the images API
+        for x in xrange(1, 10):
+            # We make even images remote and odd images standard
+            if x % 2 == 0:
+                cls.created_images.append(cls._create_remote_image(x))
+            else:
+                cls.created_images.append(cls._create_standard_image(x))
+
+    @classmethod
+    def tearDownClass(cls):
+        for image_id in cls.created_images:
+            cls.client.delete_image(image_id)
+
+    @classmethod
+    def _create_remote_image(cls, x):
+        """
+        Create a new remote image and return the ID of the newly-registered
+        image
+        """
+        meta = {
+            'name': 'New Remote Image %s' % x,
+            'is_public': True,
+            'disk_format': 'raw',
+            'container_format': 'bare',
+            'location': 'http://example.com/someimage_%s.iso' % x
+        }
+        results = cls.client.add_image(meta)
+        image_id = results['id']
+        return image_id
+
+    @classmethod
+    def _create_standard_image(cls, x):
+        """
+        Create a new standard image and return the ID of the newly-registered
+        image. Note that the size of the new image is a random number between
+        1024 and 4096
+        """
+        meta = {
+            'name': 'New Standard Image %s' % x,
+            'is_public': True,
+            'disk_format': 'raw',
+            'container_format': 'bare'
+        }
+        image_file = StringIO.StringIO('*' * random.randint(1024, 4096))
+        results = cls.client.add_image(meta, image_file)
+        image_id = results['id']
+        return image_id
+
+    @attr(type='image')
+    def test_index_no_params(self):
+        """
+        Simple test to see all fixture images returned
+        """
+        images = self.client.get_images()
+        self.assertEqual(10, len(images) - len(cls.original_images))
diff --git a/tempest/tools/conf_from_devstack b/tempest/tools/conf_from_devstack
index f3f1309..15346d9 100755
--- a/tempest/tools/conf_from_devstack
+++ b/tempest/tools/conf_from_devstack
@@ -135,8 +135,7 @@
     if options.verbose:
         print "Found base image with UUID %s" % conf_settings['base_image_uuid']
 
-    tempest_conf = """
-[nova]
+    tempest_conf = """[nova]
 host=%(service_host)s
 port=%(service_port)s
 apiVer=%(identity_api_version)s
@@ -148,6 +147,16 @@
 build_interval=10
 build_timeout=600
 
+[image]
+host = %(service_host)s
+port = 9292
+username = %(user)s
+password = %(password)s
+tenant = %(user)s
+auth_url = http://127.0.0.1:5000/v2.0/tokens/
+strategy = keystone
+service_token = servicetoken
+
 [environment]
 image_ref=%(base_image_uuid)s
 image_ref_alt=4