Blob Blame History Raw
From d44afa05c270335d6c9812913f35d75669f0886a Mon Sep 17 00:00:00 2001
From: Eoghan Glynn <eglynn@redhat.com>
Date: Fri, 18 May 2012 14:23:41 +0100
Subject: [PATCH] Support DB auto-create suppression.

Adds a new boolean config option, db_auto_create, to allow the
DB auto-creation be suppressed on demand. This defaults to True
for now to maintain the pre-existing behaviour, but should be
changed to False before the Folsom release.

The 'glance-manage db_sync' command will now create the image*
tables if the DB did not previously exist. The db_auto_create
flag is irrelevant in that case.

The @glance.tests.function.runs_sql annotation is now obsolete
as the glance-api/registry services launched by functional tests
must now all run against an on-disk sqlite instance (as opposed
to in-memory, as this makes no sense when the DB tables are
created in advance).

Change-Id: I05fc6b3ca7691dfaf00bc75a0743c921c93b9694

Conflicts:

	glance/tests/functional/__init__.py
	glance/tests/functional/test_sqlite.py
	glance/tests/functional/v1/test_api.py
	glance/tests/functional/v2/test_images.py
---
 bin/glance-manage                             |   10 +-
 etc/glance-registry.conf                      |    4 +
 glance/registry/db/api.py                     |   26 +-
 glance/tests/functional/__init__.py           |   58 +-
 glance/tests/functional/test_bin_glance.py    |    3 -
 glance/tests/functional/test_glance_manage.py |   77 ++
 glance/tests/functional/test_sqlite.py        |    1 -
 glance/tests/functional/v1/test_api.py        | 1357 +++++++++++++++++++++++++
 glance/tests/functional/v2/test_images.py     |  468 +++++++++
 9 files changed, 1964 insertions(+), 40 deletions(-)
 create mode 100644 glance/tests/functional/test_glance_manage.py
 create mode 100644 glance/tests/functional/v1/test_api.py
 create mode 100644 glance/tests/functional/v2/test_images.py

diff --git a/bin/glance-manage b/bin/glance-manage
index 3a50c11..aeee4fd 100755
--- a/bin/glance-manage
+++ b/bin/glance-manage
@@ -44,6 +44,7 @@ from glance.common import cfg
 from glance.common import config
 from glance.common import exception
 import glance.registry.db
+import glance.registry.db.api
 import glance.registry.db.migration
 
 
@@ -75,7 +76,14 @@ def do_version_control(conf, args):
 
 
 def do_db_sync(conf, args):
-    """Place a database under migration control and upgrade"""
+    """
+    Place a database under migration control and upgrade,
+    creating first if necessary.
+    """
+    # override auto-create flag, as complete DB should always
+    # be created on sync if not already existing
+    conf.db_auto_create = True
+    glance.registry.db.api.configure_db(conf)
     version = args.pop(0) if args else None
     current_version = args.pop(0) if args else None
     glance.registry.db.migration.db_sync(conf, version, current_version)
diff --git a/etc/glance-registry.conf b/etc/glance-registry.conf
index 8597411..37d71ff 100644
--- a/etc/glance-registry.conf
+++ b/etc/glance-registry.conf
@@ -23,6 +23,10 @@ backlog = 4096
 # See: http://www.sqlalchemy.org/docs/05/reference/sqlalchemy/connections.html#sqlalchemy.create_engine
 sql_connection = sqlite:///glance.sqlite
 
+# Whether the glance service creates the database tables
+# automatically at startup, or explicitly with db_sync
+db_auto_create = True
+
 # Period in seconds after which SQLAlchemy should reestablish its connection
 # to the database.
 #
diff --git a/glance/registry/db/api.py b/glance/registry/db/api.py
index 04fe2e5..b2e3c00 100644
--- a/glance/registry/db/api.py
+++ b/glance/registry/db/api.py
@@ -68,7 +68,8 @@ db_opts = [
     cfg.IntOpt('sql_idle_timeout', default=3600),
     cfg.StrOpt('sql_connection', default='sqlite:///glance.sqlite'),
     cfg.IntOpt('sql_max_retries', default=10),
-    cfg.IntOpt('sql_retry_interval', default=1)
+    cfg.IntOpt('sql_retry_interval', default=1),
+    cfg.BoolOpt('db_auto_create', default=True),
     ]
 
 
@@ -102,7 +103,12 @@ def configure_db(conf):
     """
     global _ENGINE, sa_logger, logger, _MAX_RETRIES, _RETRY_INTERVAL
     if not _ENGINE:
-        conf.register_opts(db_opts)
+        for opt in db_opts:
+            # avoid duplicate registration
+            try:
+                getattr(conf, opt.name)
+            except cfg.NoSuchOptError:
+                conf.register_opt(opt)
         sql_connection = conf.sql_connection
         _MAX_RETRIES = conf.sql_max_retries
         _RETRY_INTERVAL = conf.sql_retry_interval
@@ -131,12 +137,16 @@ def configure_db(conf):
         elif conf.verbose:
             sa_logger.setLevel(logging.INFO)
 
-        models.register_models(_ENGINE)
-        try:
-            migration.version_control(conf)
-        except exception.DatabaseMigrationError:
-            # only arises when the DB exists and is under version control
-            pass
+        if conf.db_auto_create:
+            logger.info('auto-creating glance registry DB')
+            models.register_models(_ENGINE)
+            try:
+                migration.version_control(conf)
+            except exception.DatabaseMigrationError:
+                # only arises when the DB exists and is under version control
+                pass
+        else:
+            logger.info('not auto-creating glance registry DB')
 
 
 def check_mutate_authorization(context, image_ref):
diff --git a/glance/tests/functional/__init__.py b/glance/tests/functional/__init__.py
index 5260a89..da0c944 100644
--- a/glance/tests/functional/__init__.py
+++ b/glance/tests/functional/__init__.py
@@ -43,27 +43,6 @@ from glance.tests import utils as test_utils
 execute, get_unused_port = test_utils.execute, test_utils.get_unused_port
 
 
-def runs_sql(func):
-    """
-    Decorator for a test case method that ensures that the
-    sql_connection setting is overridden to ensure a disk-based
-    SQLite database so that arbitrary SQL statements can be
-    executed out-of-process against the datastore...
-    """
-    @functools.wraps(func)
-    def wrapped(*a, **kwargs):
-        test_obj = a[0]
-        orig_sql_connection = test_obj.registry_server.sql_connection
-        try:
-            if orig_sql_connection.startswith('sqlite'):
-                test_obj.registry_server.sql_connection =\
-                        "sqlite:///tests.sqlite"
-            func(*a, **kwargs)
-        finally:
-            test_obj.registry_server.sql_connection = orig_sql_connection
-    return wrapped
-
-
 class Server(object):
     """
     Class used to easily manage starting and stopping
@@ -89,6 +68,7 @@ class Server(object):
         self.exec_env = None
         self.deployment_flavor = ''
         self.server_control_options = ''
+        self.needs_database = False
 
     def write_conf(self, **kwargs):
         """
@@ -145,6 +125,8 @@ class Server(object):
         # Ensure the configuration file is written
         overridden = self.write_conf(**kwargs)[1]
 
+        self.create_database()
+
         cmd = ("%(server_control)s %(server_name)s start "
                "%(conf_file_name)s --pid-file=%(pid_file)s "
                "%(server_control_options)s"
@@ -156,6 +138,23 @@ class Server(object):
                        expected_exitcode=expected_exitcode,
                        context=overridden)
 
+    def create_database(self):
+        """Create database if required for this server"""
+        if self.needs_database:
+            conf_dir = os.path.join(self.test_dir, 'etc')
+            utils.safe_mkdirs(conf_dir)
+            conf_filepath = os.path.join(conf_dir, 'glance-manage.conf')
+
+            with open(conf_filepath, 'wb') as conf_file:
+                conf_file.write('[DEFAULT]\n')
+                conf_file.write('sql_connection = %s' % self.sql_connection)
+                conf_file.flush()
+
+            cmd = ('bin/glance-manage db_sync --config-file %s'
+                   % conf_filepath)
+            execute(cmd, no_venv=self.no_venv, exec_env=self.exec_env,
+                    expect_exit=True)
+
     def stop(self):
         """
         Spin down the server.
@@ -212,6 +211,12 @@ class ApiServer(Server):
         self.policy_file = policy_file
         self.policy_default_rule = 'default'
         self.server_control_options = '--capture-output'
+
+        self.needs_database = True
+        default_sql_connection = 'sqlite:///tests.sqlite'
+        self.sql_connection = os.environ.get('GLANCE_TEST_SQL_CONNECTION',
+                                             default_sql_connection)
+
         self.conf_base = """[DEFAULT]
 verbose = %(verbose)s
 debug = %(debug)s
@@ -248,6 +253,8 @@ image_cache_dir = %(image_cache_dir)s
 image_cache_driver = %(image_cache_driver)s
 policy_file = %(policy_file)s
 policy_default_rule = %(policy_default_rule)s
+db_auto_create = False
+sql_connection = %(sql_connection)s
 [paste_deploy]
 flavor = %(deployment_flavor)s
 """
@@ -300,7 +307,8 @@ class RegistryServer(Server):
         super(RegistryServer, self).__init__(test_dir, port)
         self.server_name = 'registry'
 
-        default_sql_connection = 'sqlite:///'
+        self.needs_database = True
+        default_sql_connection = 'sqlite:///tests.sqlite'
         self.sql_connection = os.environ.get('GLANCE_TEST_SQL_CONNECTION',
                                              default_sql_connection)
 
@@ -315,6 +323,7 @@ debug = %(debug)s
 bind_host = 0.0.0.0
 bind_port = %(bind_port)s
 log_file = %(log_file)s
+db_auto_create = False
 sql_connection = %(sql_connection)s
 sql_idle_timeout = 3600
 api_limit_max = 1000
@@ -625,11 +634,6 @@ class FunctionalTest(unittest.TestCase):
         if os.path.exists(self.test_dir):
             shutil.rmtree(self.test_dir)
 
-        # We do this here because the @runs_sql decorator above
-        # actually resets the registry server's sql_connection
-        # to the original (usually memory-based SQLite connection)
-        # and this block of code is run *before* the finally:
-        # block in that decorator...
         self._reset_database(self.registry_server.sql_connection)
 
     def run_sql_cmd(self, sql):
diff --git a/glance/tests/functional/test_bin_glance.py b/glance/tests/functional/test_bin_glance.py
index a989b58..5393872 100644
--- a/glance/tests/functional/test_bin_glance.py
+++ b/glance/tests/functional/test_bin_glance.py
@@ -643,7 +643,6 @@ class TestBinGlance(functional.FunctionalTest):
 
         self.stop_servers()
 
-    @functional.runs_sql
     def test_add_location_with_checksum(self):
         """
         We test the following:
@@ -675,7 +674,6 @@ class TestBinGlance(functional.FunctionalTest):
 
         self.stop_servers()
 
-    @functional.runs_sql
     def test_add_location_without_checksum(self):
         """
         We test the following:
@@ -707,7 +705,6 @@ class TestBinGlance(functional.FunctionalTest):
 
         self.stop_servers()
 
-    @functional.runs_sql
     def test_add_clear(self):
         """
         We test the following:
diff --git a/glance/tests/functional/test_glance_manage.py b/glance/tests/functional/test_glance_manage.py
new file mode 100644
index 0000000..4b627c5
--- /dev/null
+++ b/glance/tests/functional/test_glance_manage.py
@@ -0,0 +1,77 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2012 Red Hat, Inc
+# 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.
+
+"""Functional test cases for glance-manage"""
+
+import os
+
+from glance.common import utils
+from glance.tests import functional
+from glance.tests.utils import execute, depends_on_exe, skip_if_disabled
+
+
+class TestGlanceManage(functional.FunctionalTest):
+    """Functional tests for glance-manage"""
+
+    def setUp(self):
+        super(TestGlanceManage, self).setUp()
+        conf_dir = os.path.join(self.test_dir, 'etc')
+        utils.safe_mkdirs(conf_dir)
+        self.conf_filepath = os.path.join(conf_dir, 'glance-manage.conf')
+        self.db_filepath = os.path.join(conf_dir, 'test.sqlite')
+        self.connection = ('sql_connection = sqlite:///%s' %
+                           self.db_filepath)
+
+    def _sync_db(self, auto_create):
+        with open(self.conf_filepath, 'wb') as conf_file:
+            conf_file.write('[DEFAULT]\n')
+            conf_file.write('db_auto_create = %r\n' % auto_create)
+            conf_file.write(self.connection)
+            conf_file.flush()
+
+        cmd = ('bin/glance-manage db_sync --config-file %s' %
+               self.conf_filepath)
+        execute(cmd, raise_error=True)
+
+    def _assert_tables(self):
+        cmd = "sqlite3 %s '.schema'" % self.db_filepath
+        exitcode, out, err = execute(cmd, raise_error=True)
+
+        self.assertTrue('CREATE TABLE images' in out)
+        self.assertTrue('CREATE TABLE image_tags' in out)
+        self.assertTrue('CREATE TABLE image_members' in out)
+        self.assertTrue('CREATE TABLE image_properties' in out)
+
+    @depends_on_exe('sqlite3')
+    @skip_if_disabled
+    def test_db_creation(self):
+        """Test DB creation by db_sync on a fresh DB"""
+        self._sync_db(True)
+
+        self._assert_tables()
+
+        self.stop_servers()
+
+    @depends_on_exe('sqlite3')
+    @skip_if_disabled
+    def test_db_creation_auto_create_overridden(self):
+        """Test DB creation with db_auto_create False"""
+        self._sync_db(False)
+
+        self._assert_tables()
+
+        self.stop_servers()
diff --git a/glance/tests/functional/test_sqlite.py b/glance/tests/functional/test_sqlite.py
index 4cfff6a..36afcb3 100644
--- a/glance/tests/functional/test_sqlite.py
+++ b/glance/tests/functional/test_sqlite.py
@@ -25,7 +25,6 @@ from glance.tests.utils import execute
 class TestSqlite(functional.FunctionalTest):
     """Functional tests for sqlite-specific logic"""
 
-    @functional.runs_sql
     def test_big_int_mapping(self):
         """Ensure BigInteger not mapped to BIGINT"""
         self.cleanup()
diff --git a/glance/tests/functional/v1/test_api.py b/glance/tests/functional/v1/test_api.py
new file mode 100644
index 0000000..b22e88f
--- /dev/null
+++ b/glance/tests/functional/v1/test_api.py
@@ -0,0 +1,1357 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright 2011 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.
+
+"""Functional test case that utilizes httplib2 against the API server"""
+
+import datetime
+import hashlib
+import json
+import tempfile
+
+import httplib2
+
+from glance.common import utils
+from glance.tests import functional
+from glance.tests.utils import skip_if_disabled, minimal_headers
+
+FIVE_KB = 5 * 1024
+FIVE_GB = 5 * 1024 * 1024 * 1024
+
+
+class TestApi(functional.FunctionalTest):
+
+    """Functional tests using httplib2 against the API server"""
+
+    @skip_if_disabled
+    def test_get_head_simple_post(self):
+        """
+        We test the following sequential series of actions:
+
+        0. GET /images
+        - Verify no public images
+        1. GET /images/detail
+        - Verify no public images
+        2. POST /images with public image named Image1
+        and no custom properties
+        - Verify 201 returned
+        3. HEAD image
+        - Verify HTTP headers have correct information we just added
+        4. GET image
+        - Verify all information on image we just added is correct
+        5. GET /images
+        - Verify the image we just added is returned
+        6. GET /images/detail
+        - Verify the image we just added is returned
+        7. PUT image with custom properties of "distro" and "arch"
+        - Verify 200 returned
+        8. GET image
+        - Verify updated information about image was stored
+        9. PUT image
+        - Remove a previously existing property.
+        10. PUT image
+        - Add a previously deleted property.
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        # 1. GET /images/detail
+        # Verify no public images
+        path = "http://%s:%d/v1/images/detail" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        # 2. POST /images with public image named Image1
+        # attribute and no custom properties. Verify a 200 OK is returned
+        image_data = "*" * FIVE_KB
+        headers = minimal_headers('Image1')
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers,
+                                         body=image_data)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        image_id = data['image']['id']
+        self.assertEqual(data['image']['checksum'],
+                         hashlib.md5(image_data).hexdigest())
+        self.assertEqual(data['image']['size'], FIVE_KB)
+        self.assertEqual(data['image']['name'], "Image1")
+        self.assertEqual(data['image']['is_public'], True)
+
+        # 3. HEAD image
+        # Verify image found now
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(response['x-image-meta-name'], "Image1")
+
+        # 4. GET image
+        # Verify all information on image we just added is correct
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+
+        expected_image_headers = {
+            'x-image-meta-id': image_id,
+            'x-image-meta-name': 'Image1',
+            'x-image-meta-is_public': 'True',
+            'x-image-meta-status': 'active',
+            'x-image-meta-disk_format': 'raw',
+            'x-image-meta-container_format': 'ovf',
+            'x-image-meta-size': str(FIVE_KB)}
+
+        expected_std_headers = {
+            'content-length': str(FIVE_KB),
+            'content-type': 'application/octet-stream'}
+
+        for expected_key, expected_value in expected_image_headers.items():
+            self.assertEqual(response[expected_key], expected_value,
+                            "For key '%s' expected header value '%s'. Got '%s'"
+                            % (expected_key, expected_value,
+                               response[expected_key]))
+
+        for expected_key, expected_value in expected_std_headers.items():
+            self.assertEqual(response[expected_key], expected_value,
+                            "For key '%s' expected header value '%s'. Got '%s'"
+                            % (expected_key,
+                               expected_value,
+                               response[expected_key]))
+
+        self.assertEqual(content, "*" * FIVE_KB)
+        self.assertEqual(hashlib.md5(content).hexdigest(),
+                         hashlib.md5("*" * FIVE_KB).hexdigest())
+
+        # 5. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+
+        expected_result = {"images": [
+            {"container_format": "ovf",
+             "disk_format": "raw",
+             "id": image_id,
+             "name": "Image1",
+             "checksum": "c2e5db72bd7fd153f53ede5da5a06de3",
+             "size": 5120}]}
+        self.assertEqual(json.loads(content), expected_result)
+
+        # 6. GET /images/detail
+        # Verify image and all its metadata
+        path = "http://%s:%d/v1/images/detail" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+
+        expected_image = {
+            "status": "active",
+            "name": "Image1",
+            "deleted": False,
+            "container_format": "ovf",
+            "disk_format": "raw",
+            "id": image_id,
+            "is_public": True,
+            "deleted_at": None,
+            "properties": {},
+            "size": 5120}
+
+        image = json.loads(content)
+
+        for expected_key, expected_value in expected_image.items():
+            self.assertEqual(expected_value, image['images'][0][expected_key],
+                            "For key '%s' expected header value '%s'. Got '%s'"
+                            % (expected_key,
+                               expected_value,
+                               image['images'][0][expected_key]))
+
+        # 7. PUT image with custom properties of "distro" and "arch"
+        # Verify 200 returned
+        headers = {'X-Image-Meta-Property-Distro': 'Ubuntu',
+                   'X-Image-Meta-Property-Arch': 'x86_64'}
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT', headers=headers)
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(data['image']['properties']['arch'], "x86_64")
+        self.assertEqual(data['image']['properties']['distro'], "Ubuntu")
+
+        # 8. GET /images/detail
+        # Verify image and all its metadata
+        path = "http://%s:%d/v1/images/detail" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+
+        expected_image = {
+            "status": "active",
+            "name": "Image1",
+            "deleted": False,
+            "container_format": "ovf",
+            "disk_format": "raw",
+            "id": image_id,
+            "is_public": True,
+            "deleted_at": None,
+            "properties": {'distro': 'Ubuntu', 'arch': 'x86_64'},
+            "size": 5120}
+
+        image = json.loads(content)
+
+        for expected_key, expected_value in expected_image.items():
+            self.assertEqual(expected_value, image['images'][0][expected_key],
+                            "For key '%s' expected header value '%s'. Got '%s'"
+                            % (expected_key,
+                               expected_value,
+                               image['images'][0][expected_key]))
+
+        # 9. PUT image and remove a previously existing property.
+        headers = {'X-Image-Meta-Property-Arch': 'x86_64'}
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT', headers=headers)
+        self.assertEqual(response.status, 200)
+
+        path = "http://%s:%d/v1/images/detail" % ("0.0.0.0", self.api_port)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)['images'][0]
+        self.assertEqual(len(data['properties']), 1)
+        self.assertEqual(data['properties']['arch'], "x86_64")
+
+        # 10. PUT image and add a previously deleted property.
+        headers = {'X-Image-Meta-Property-Distro': 'Ubuntu',
+                   'X-Image-Meta-Property-Arch': 'x86_64'}
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT', headers=headers)
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+
+        path = "http://%s:%d/v1/images/detail" % ("0.0.0.0", self.api_port)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)['images'][0]
+        self.assertEqual(len(data['properties']), 2)
+        self.assertEqual(data['properties']['arch'], "x86_64")
+        self.assertEqual(data['properties']['distro'], "Ubuntu")
+
+        # DELETE image
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'DELETE')
+        self.assertEqual(response.status, 200)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_queued_process_flow(self):
+        """
+        We test the process flow where a user registers an image
+        with Glance but does not immediately upload an image file.
+        Later, the user uploads an image file using a PUT operation.
+        We track the changing of image status throughout this process.
+
+        0. GET /images
+        - Verify no public images
+        1. POST /images with public image named Image1 with no location
+           attribute and no image data.
+        - Verify 201 returned
+        2. GET /images
+        - Verify one public image
+        3. HEAD image
+        - Verify image now in queued status
+        4. PUT image with image data
+        - Verify 200 returned
+        5. HEAD images
+        - Verify image now in active status
+        6. GET /images
+        - Verify one public image
+        """
+
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        # 1. POST /images with public image named Image1
+        # with no location or image data
+        headers = minimal_headers('Image1')
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        self.assertEqual(data['image']['checksum'], None)
+        self.assertEqual(data['image']['size'], 0)
+        self.assertEqual(data['image']['container_format'], 'ovf')
+        self.assertEqual(data['image']['disk_format'], 'raw')
+        self.assertEqual(data['image']['name'], "Image1")
+        self.assertEqual(data['image']['is_public'], True)
+
+        image_id = data['image']['id']
+
+        # 2. GET /images
+        # Verify 1 public image
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(data['images'][0]['id'], image_id)
+        self.assertEqual(data['images'][0]['checksum'], None)
+        self.assertEqual(data['images'][0]['size'], 0)
+        self.assertEqual(data['images'][0]['container_format'], 'ovf')
+        self.assertEqual(data['images'][0]['disk_format'], 'raw')
+        self.assertEqual(data['images'][0]['name'], "Image1")
+
+        # 3. HEAD /images
+        # Verify status is in queued
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(response['x-image-meta-name'], "Image1")
+        self.assertEqual(response['x-image-meta-status'], "queued")
+        self.assertEqual(response['x-image-meta-size'], '0')
+        self.assertEqual(response['x-image-meta-id'], image_id)
+
+        # 4. PUT image with image data, verify 200 returned
+        image_data = "*" * FIVE_KB
+        headers = {'Content-Type': 'application/octet-stream'}
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                             image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT', headers=headers,
+                                         body=image_data)
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(data['image']['checksum'],
+                         hashlib.md5(image_data).hexdigest())
+        self.assertEqual(data['image']['size'], FIVE_KB)
+        self.assertEqual(data['image']['name'], "Image1")
+        self.assertEqual(data['image']['is_public'], True)
+
+        # 5. HEAD /images
+        # Verify status is in active
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(response['x-image-meta-name'], "Image1")
+        self.assertEqual(response['x-image-meta-status'], "active")
+
+        # 6. GET /images
+        # Verify 1 public image still...
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(data['images'][0]['checksum'],
+                         hashlib.md5(image_data).hexdigest())
+        self.assertEqual(data['images'][0]['id'], image_id)
+        self.assertEqual(data['images'][0]['size'], FIVE_KB)
+        self.assertEqual(data['images'][0]['container_format'], 'ovf')
+        self.assertEqual(data['images'][0]['disk_format'], 'raw')
+        self.assertEqual(data['images'][0]['name'], "Image1")
+
+        # DELETE image
+        path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                              image_id)
+        http = httplib2.Http()
+        response, content = http.request(path, 'DELETE')
+        self.assertEqual(response.status, 200)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_size_greater_2G_mysql(self):
+        """
+        A test against the actual datastore backend for the registry
+        to ensure that the image size property is not truncated.
+
+        :see https://bugs.launchpad.net/glance/+bug/739433
+        """
+
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 1. POST /images with public image named Image1
+        # attribute and a size of 5G. Use the HTTP engine with an
+        # X-Image-Meta-Location attribute to make Glance forego
+        # "adding" the image data.
+        # Verify a 201 OK is returned
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Location': 'http://example.com/fakeimage',
+                   'X-Image-Meta-Size': str(FIVE_GB),
+                   'X-Image-Meta-Name': 'Image1',
+                   'X-Image-Meta-disk_format': 'raw',
+                   'X-image-Meta-container_format': 'ovf',
+                   'X-Image-Meta-Is-Public': 'True'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+
+        # 2. HEAD /images
+        # Verify image size is what was passed in, and not truncated
+        path = response.get('location')
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(response['x-image-meta-size'], str(FIVE_GB))
+        self.assertEqual(response['x-image-meta-name'], 'Image1')
+        self.assertEqual(response['x-image-meta-is_public'], 'True')
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_traceback_not_consumed(self):
+        """
+        A test that errors coming from the POST API do not
+        get consumed and print the actual error message, and
+        not something like &lt;traceback object at 0x1918d40&gt;
+
+        :see https://bugs.launchpad.net/glance/+bug/755912
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # POST /images with binary data, but not setting
+        # Content-Type to application/octet-stream, verify a
+        # 400 returned and that the error is readable.
+        with tempfile.NamedTemporaryFile() as test_data_file:
+            test_data_file.write("XXX")
+            test_data_file.flush()
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST',
+                            body=test_data_file.name)
+        self.assertEqual(response.status, 400)
+        expected = "Content-Type must be application/octet-stream"
+        self.assertTrue(expected in content,
+                        "Could not find '%s' in '%s'" % (expected, content))
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_filtered_images(self):
+        """
+        Set up four test images and ensure each query param filter works
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        image_ids = []
+
+        # 1. POST /images with three public images, and one private image
+        # with various attributes
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'Image1',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'ovf',
+                   'X-Image-Meta-Disk-Format': 'vdi',
+                   'X-Image-Meta-Size': '19',
+                   'X-Image-Meta-Is-Public': 'True',
+                   'X-Image-Meta-Protected': 'True',
+                   'X-Image-Meta-Property-pants': 'are on'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        self.assertEqual(data['image']['properties']['pants'], "are on")
+        self.assertEqual(data['image']['is_public'], True)
+        image_ids.append(data['image']['id'])
+
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'My Image!',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'ovf',
+                   'X-Image-Meta-Disk-Format': 'vhd',
+                   'X-Image-Meta-Size': '20',
+                   'X-Image-Meta-Is-Public': 'True',
+                   'X-Image-Meta-Protected': 'False',
+                   'X-Image-Meta-Property-pants': 'are on'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        self.assertEqual(data['image']['properties']['pants'], "are on")
+        self.assertEqual(data['image']['is_public'], True)
+        image_ids.append(data['image']['id'])
+
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'My Image!',
+                   'X-Image-Meta-Status': 'saving',
+                   'X-Image-Meta-Container-Format': 'ami',
+                   'X-Image-Meta-Disk-Format': 'ami',
+                   'X-Image-Meta-Size': '21',
+                   'X-Image-Meta-Is-Public': 'True',
+                   'X-Image-Meta-Protected': 'False',
+                   'X-Image-Meta-Property-pants': 'are off'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        self.assertEqual(data['image']['properties']['pants'], "are off")
+        self.assertEqual(data['image']['is_public'], True)
+        image_ids.append(data['image']['id'])
+
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'My Private Image',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'ami',
+                   'X-Image-Meta-Disk-Format': 'ami',
+                   'X-Image-Meta-Size': '22',
+                   'X-Image-Meta-Is-Public': 'False',
+                   'X-Image-Meta-Protected': 'False'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        self.assertEqual(data['image']['is_public'], False)
+        image_ids.append(data['image']['id'])
+
+        # 2. GET /images
+        # Verify three public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+
+        # 3. GET /images with name filter
+        # Verify correct images returned with name
+        params = "name=My%20Image!"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        for image in data['images']:
+            self.assertEqual(image['name'], "My Image!")
+
+        # 4. GET /images with status filter
+        # Verify correct images returned with status
+        params = "status=queued"
+        path = "http://%s:%d/v1/images/detail?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+        for image in data['images']:
+            self.assertEqual(image['status'], "queued")
+
+        params = "status=active"
+        path = "http://%s:%d/v1/images/detail?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 0)
+
+        # 5. GET /images with container_format filter
+        # Verify correct images returned with container_format
+        params = "container_format=ovf"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        for image in data['images']:
+            self.assertEqual(image['container_format'], "ovf")
+
+        # 6. GET /images with disk_format filter
+        # Verify correct images returned with disk_format
+        params = "disk_format=vdi"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 1)
+        for image in data['images']:
+            self.assertEqual(image['disk_format'], "vdi")
+
+        # 7. GET /images with size_max filter
+        # Verify correct images returned with size <= expected
+        params = "size_max=20"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        for image in data['images']:
+            self.assertTrue(image['size'] <= 20)
+
+        # 8. GET /images with size_min filter
+        # Verify correct images returned with size >= expected
+        params = "size_min=20"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        for image in data['images']:
+            self.assertTrue(image['size'] >= 20)
+
+        # 9. Get /images with is_public=None filter
+        # Verify correct images returned with property
+        # Bug lp:803656  Support is_public in filtering
+        params = "is_public=None"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 4)
+
+        # 10. Get /images with is_public=False filter
+        # Verify correct images returned with property
+        # Bug lp:803656  Support is_public in filtering
+        params = "is_public=False"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 1)
+        for image in data['images']:
+            self.assertEqual(image['name'], "My Private Image")
+
+        # 11. Get /images with is_public=True filter
+        # Verify correct images returned with property
+        # Bug lp:803656  Support is_public in filtering
+        params = "is_public=True"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+        for image in data['images']:
+            self.assertNotEqual(image['name'], "My Private Image")
+
+        # 12. Get /images with protected=False filter
+        # Verify correct images returned with property
+        params = "protected=False"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        for image in data['images']:
+            self.assertNotEqual(image['name'], "Image1")
+
+        # 13. Get /images with protected=True filter
+        # Verify correct images returned with property
+        params = "protected=True"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 1)
+        for image in data['images']:
+            self.assertEqual(image['name'], "Image1")
+
+        # 14. GET /images with property filter
+        # Verify correct images returned with property
+        params = "property-pants=are%20on"
+        path = "http://%s:%d/v1/images/detail?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        for image in data['images']:
+            self.assertEqual(image['properties']['pants'], "are on")
+
+        # 15. GET /images with property filter and name filter
+        # Verify correct images returned with property and name
+        # Make sure you quote the url when using more than one param!
+        params = "name=My%20Image!&property-pants=are%20on"
+        path = "http://%s:%d/v1/images/detail?%s" % (
+                "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 1)
+        for image in data['images']:
+            self.assertEqual(image['properties']['pants'], "are on")
+            self.assertEqual(image['name'], "My Image!")
+
+        # 16. GET /images with past changes-since filter
+        yesterday = utils.isotime(datetime.datetime.utcnow() -
+                                  datetime.timedelta(1))
+        params = "changes-since=%s" % yesterday
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+
+        # one timezone west of Greenwich equates to an hour ago
+        # taking care to pre-urlencode '+' as '%2B', otherwise the timezone
+        # '+' is wrongly decoded as a space
+        # TODO(eglynn): investigate '+' --> <SPACE> decoding, an artifact
+        # of WSGI/webob dispatch?
+        now = datetime.datetime.utcnow()
+        hour_ago = now.strftime('%Y-%m-%dT%H:%M:%S%%2B01:00')
+        params = "changes-since=%s" % hour_ago
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+
+        # 17. GET /images with future changes-since filter
+        tomorrow = utils.isotime(datetime.datetime.utcnow() +
+                                 datetime.timedelta(1))
+        params = "changes-since=%s" % tomorrow
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 0)
+
+        # one timezone east of Greenwich equates to an hour from now
+        now = datetime.datetime.utcnow()
+        hour_hence = now.strftime('%Y-%m-%dT%H:%M:%S-01:00')
+        params = "changes-since=%s" % hour_hence
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 0)
+
+        # 18. GET /images with size_min filter
+        # Verify correct images returned with size >= expected
+        params = "size_min=-1"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 400)
+        self.assertTrue("filter size_min got -1" in content)
+
+        # 19. GET /images with size_min filter
+        # Verify correct images returned with size >= expected
+        params = "size_max=-1"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 400)
+        self.assertTrue("filter size_max got -1" in content)
+
+        # 20. GET /images with size_min filter
+        # Verify correct images returned with size >= expected
+        params = "min_ram=-1"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 400)
+        self.assertTrue("Bad value passed to filter min_ram got -1" in content)
+
+        # 21. GET /images with size_min filter
+        # Verify correct images returned with size >= expected
+        params = "protected=imalittleteapot"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 400)
+        self.assertTrue("protected got imalittleteapot" in content)
+
+        # 22. GET /images with size_min filter
+        # Verify correct images returned with size >= expected
+        params = "is_public=imalittleteapot"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 400)
+        self.assertTrue("is_public got imalittleteapot" in content)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_limited_images(self):
+        """
+        Ensure marker and limit query params work
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        image_ids = []
+
+        # 1. POST /images with three public images with various attributes
+        headers = minimal_headers('Image1')
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        image_ids.append(json.loads(content)['image']['id'])
+
+        headers = minimal_headers('Image2')
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        image_ids.append(json.loads(content)['image']['id'])
+
+        headers = minimal_headers('Image3')
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        image_ids.append(json.loads(content)['image']['id'])
+
+        # 2. GET /images with all images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        images = json.loads(content)['images']
+        self.assertEqual(len(images), 3)
+
+        # 3. GET /images with limit of 2
+        # Verify only two images were returned
+        params = "limit=2"
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)['images']
+        self.assertEqual(len(data), 2)
+        self.assertEqual(data[0]['id'], images[0]['id'])
+        self.assertEqual(data[1]['id'], images[1]['id'])
+
+        # 4. GET /images with marker
+        # Verify only two images were returned
+        params = "marker=%s" % images[0]['id']
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)['images']
+        self.assertEqual(len(data), 2)
+        self.assertEqual(data[0]['id'], images[1]['id'])
+        self.assertEqual(data[1]['id'], images[2]['id'])
+
+        # 5. GET /images with marker and limit
+        # Verify only one image was returned with the correct id
+        params = "limit=1&marker=%s" % images[1]['id']
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)['images']
+        self.assertEqual(len(data), 1)
+        self.assertEqual(data[0]['id'], images[2]['id'])
+
+        # 6. GET /images/detail with marker and limit
+        # Verify only one image was returned with the correct id
+        params = "limit=1&marker=%s" % images[1]['id']
+        path = "http://%s:%d/v1/images?%s" % (
+               "0.0.0.0", self.api_port, params)
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)['images']
+        self.assertEqual(len(data), 1)
+        self.assertEqual(data[0]['id'], images[2]['id'])
+
+        # DELETE images
+        for image_id in image_ids:
+            path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                                  image_id)
+            http = httplib2.Http()
+            response, content = http.request(path, 'DELETE')
+            self.assertEqual(response.status, 200)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_ordered_images(self):
+        """
+        Set up three test images and ensure each query param filter works
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        # 1. POST /images with three public images with various attributes
+        image_ids = []
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'Image1',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'ovf',
+                   'X-Image-Meta-Disk-Format': 'vdi',
+                   'X-Image-Meta-Size': '19',
+                   'X-Image-Meta-Is-Public': 'True'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        image_ids.append(json.loads(content)['image']['id'])
+
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'ASDF',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'bare',
+                   'X-Image-Meta-Disk-Format': 'iso',
+                   'X-Image-Meta-Size': '2',
+                   'X-Image-Meta-Is-Public': 'True'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        image_ids.append(json.loads(content)['image']['id'])
+
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'XYZ',
+                   'X-Image-Meta-Status': 'saving',
+                   'X-Image-Meta-Container-Format': 'ami',
+                   'X-Image-Meta-Disk-Format': 'ami',
+                   'X-Image-Meta-Size': '5',
+                   'X-Image-Meta-Is-Public': 'True'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        image_ids.append(json.loads(content)['image']['id'])
+
+        # 2. GET /images with no query params
+        # Verify three public images sorted by created_at desc
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+        self.assertEqual(data['images'][0]['id'], image_ids[2])
+        self.assertEqual(data['images'][1]['id'], image_ids[1])
+        self.assertEqual(data['images'][2]['id'], image_ids[0])
+
+        # 3. GET /images sorted by name asc
+        params = 'sort_key=name&sort_dir=asc'
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+        self.assertEqual(data['images'][0]['id'], image_ids[1])
+        self.assertEqual(data['images'][1]['id'], image_ids[0])
+        self.assertEqual(data['images'][2]['id'], image_ids[2])
+
+        # 4. GET /images sorted by size desc
+        params = 'sort_key=size&sort_dir=desc'
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 3)
+        self.assertEqual(data['images'][0]['id'], image_ids[0])
+        self.assertEqual(data['images'][1]['id'], image_ids[2])
+        self.assertEqual(data['images'][2]['id'], image_ids[1])
+
+        # 5. GET /images sorted by size desc with a marker
+        params = 'sort_key=size&sort_dir=desc&marker=%s' % image_ids[0]
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 2)
+        self.assertEqual(data['images'][0]['id'], image_ids[2])
+        self.assertEqual(data['images'][1]['id'], image_ids[1])
+
+        # 6. GET /images sorted by name asc with a marker
+        params = 'sort_key=name&sort_dir=asc&marker=%s' % image_ids[2]
+        path = "http://%s:%d/v1/images?%s" % ("0.0.0.0", self.api_port, params)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        data = json.loads(content)
+        self.assertEqual(len(data['images']), 0)
+
+        # DELETE images
+        for image_id in image_ids:
+            path = "http://%s:%d/v1/images/%s" % ("0.0.0.0", self.api_port,
+                                                  image_id)
+            http = httplib2.Http()
+            response, content = http.request(path, 'DELETE')
+            self.assertEqual(response.status, 200)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_duplicate_image_upload(self):
+        """
+        Upload initial image, then attempt to upload duplicate image
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        # 1. POST /images with public image named Image1
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'Image1',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'ovf',
+                   'X-Image-Meta-Disk-Format': 'vdi',
+                   'X-Image-Meta-Size': '19',
+                   'X-Image-Meta-Is-Public': 'True'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+
+        image = json.loads(content)['image']
+
+        # 2. POST /images with public image named Image1, and ID: 1
+        headers = {'Content-Type': 'application/octet-stream',
+                   'X-Image-Meta-Name': 'Image1 Update',
+                   'X-Image-Meta-Status': 'active',
+                   'X-Image-Meta-Container-Format': 'ovf',
+                   'X-Image-Meta-Disk-Format': 'vdi',
+                   'X-Image-Meta-Size': '19',
+                   'X-Image-Meta-Id': image['id'],
+                   'X-Image-Meta-Is-Public': 'True'}
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 409)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_delete_not_existing(self):
+        """
+        We test the following:
+
+        0. GET /images/1
+        - Verify 404
+        1. DELETE /images/1
+        - Verify 404
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        api_port = self.api_port
+        registry_port = self.registry_port
+
+        # 0. GET /images
+        # Verify no public images
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'GET')
+        self.assertEqual(response.status, 200)
+        self.assertEqual(content, '{"images": []}')
+
+        # 1. DELETE /images/1
+        # Verify 404 returned
+        path = "http://%s:%d/v1/images/1" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'DELETE')
+        self.assertEqual(response.status, 404)
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def test_unsupported_default_store(self):
+        """
+        We test that a mis-configured default_store causes the API server
+        to fail to start.
+        """
+        self.cleanup()
+        self.default_store = 'shouldnotexist'
+
+        # ensure failure exit code is available to assert on
+        self.api_server.server_control_options += ' --await-child=1'
+
+        # ensure that the API server fails to launch
+        self.start_server(self.api_server,
+                          expect_launch=False,
+                          expected_exitcode=255,
+                          **self.__dict__.copy())
+
+    def _do_test_post_image_content_missing_format(self, format):
+        """
+        We test that missing container/disk format fails with 400 "Bad Request"
+
+        :see https://bugs.launchpad.net/glance/+bug/933702
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+
+        # POST /images without given format being specified
+        headers = minimal_headers('Image1')
+        del headers['X-Image-Meta-' + format]
+        with tempfile.NamedTemporaryFile() as test_data_file:
+            test_data_file.write("XXX")
+            test_data_file.flush()
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST',
+                            headers=headers,
+                            body=test_data_file.name)
+        self.assertEqual(response.status, 400)
+        type = format.replace('_format', '')
+        expected = "Details: Invalid %s format 'None' for image" % type
+        self.assertTrue(expected in content,
+                        "Could not find '%s' in '%s'" % (expected, content))
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def _do_test_post_image_content_missing_diskformat(self):
+        self._do_test_post_image_content_missing_format('container_format')
+
+    @skip_if_disabled
+    def _do_test_post_image_content_missing_disk_format(self):
+        self._do_test_post_image_content_missing_format('disk_format')
+
+    def _do_test_put_image_content_missing_format(self, format):
+        """
+        We test that missing container/disk format only fails with
+        400 "Bad Request" when the image content is PUT (i.e. not
+        on the original POST of a queued image).
+
+        :see https://bugs.launchpad.net/glance/+bug/937216
+        """
+        self.cleanup()
+        self.start_servers(**self.__dict__.copy())
+
+        # POST queued image
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        headers = {
+           'X-Image-Meta-Name': 'Image1',
+           'X-Image-Meta-Is-Public': 'True',
+        }
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        image_id = data['image']['id']
+
+        # PUT image content images without given format being specified
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        headers = minimal_headers('Image1')
+        del headers['X-Image-Meta-' + format]
+        with tempfile.NamedTemporaryFile() as test_data_file:
+            test_data_file.write("XXX")
+            test_data_file.flush()
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT',
+                            headers=headers,
+                            body=test_data_file.name)
+        self.assertEqual(response.status, 400)
+        type = format.replace('_format', '')
+        expected = "Details: Invalid %s format 'None' for image" % type
+        self.assertTrue(expected in content,
+                        "Could not find '%s' in '%s'" % (expected, content))
+
+        self.stop_servers()
+
+    @skip_if_disabled
+    def _do_test_put_image_content_missing_diskformat(self):
+        self._do_test_put_image_content_missing_format('container_format')
+
+    @skip_if_disabled
+    def _do_test_put_image_content_missing_disk_format(self):
+        self._do_test_put_image_content_missing_format('disk_format')
+
+    @skip_if_disabled
+    def test_ownership(self):
+        self.cleanup()
+        self.api_server.deployment_flavor = 'fakeauth'
+        self.registry_server.deployment_flavor = 'fakeauth'
+        self.start_servers(**self.__dict__.copy())
+
+        # Add an image with admin privileges and ensure the owner
+        # can be set to something other than what was used to authenticate
+        auth_headers = {
+            'X-Auth-Token': 'user1:tenant1:admin',
+        }
+
+        create_headers = {
+            'X-Image-Meta-Name': 'MyImage',
+            'X-Image-Meta-disk_format': 'raw',
+            'X-Image-Meta-container_format': 'ovf',
+            'X-Image-Meta-Is-Public': 'True',
+            'X-Image-Meta-Owner': 'tenant2',
+        }
+        create_headers.update(auth_headers)
+
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=create_headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        image_id = data['image']['id']
+
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD', headers=auth_headers)
+        self.assertEqual(response.status, 200)
+        self.assertEqual('tenant2', response['x-image-meta-owner'])
+
+        # Now add an image without admin privileges and ensure the owner
+        # cannot be set to something other than what was used to authenticate
+        auth_headers = {
+            'X-Auth-Token': 'user1:tenant1:role1',
+        }
+        create_headers.update(auth_headers)
+
+        path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
+        http = httplib2.Http()
+        response, content = http.request(path, 'POST', headers=create_headers)
+        self.assertEqual(response.status, 201)
+        data = json.loads(content)
+        image_id = data['image']['id']
+
+        # We have to be admin to see the owner
+        auth_headers = {
+            'X-Auth-Token': 'user1:tenant1:admin',
+        }
+        create_headers.update(auth_headers)
+
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD', headers=auth_headers)
+        self.assertEqual(response.status, 200)
+        self.assertEqual('tenant1', response['x-image-meta-owner'])
+
+        # Make sure the non-privileged user can't update their owner either
+        update_headers = {
+            'X-Image-Meta-Name': 'MyImage2',
+            'X-Image-Meta-Owner': 'tenant2',
+            'X-Auth-Token': 'user1:tenant1:role1',
+        }
+
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT', headers=update_headers)
+        self.assertEqual(response.status, 200)
+
+        # We have to be admin to see the owner
+        auth_headers = {
+            'X-Auth-Token': 'user1:tenant1:admin',
+        }
+
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD', headers=auth_headers)
+        self.assertEqual(response.status, 200)
+        self.assertEqual('tenant1', response['x-image-meta-owner'])
+
+        # An admin user should be able to update the owner
+        auth_headers = {
+            'X-Auth-Token': 'user1:tenant3:admin',
+        }
+
+        update_headers = {
+            'X-Image-Meta-Name': 'MyImage2',
+            'X-Image-Meta-Owner': 'tenant2',
+        }
+        update_headers.update(auth_headers)
+
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        http = httplib2.Http()
+        response, content = http.request(path, 'PUT', headers=update_headers)
+        self.assertEqual(response.status, 200)
+
+        path = ("http://%s:%d/v1/images/%s" %
+                ("0.0.0.0", self.api_port, image_id))
+        http = httplib2.Http()
+        response, content = http.request(path, 'HEAD', headers=auth_headers)
+        self.assertEqual(response.status, 200)
+        self.assertEqual('tenant2', response['x-image-meta-owner'])
+
+        self.stop_servers()
diff --git a/glance/tests/functional/v2/test_images.py b/glance/tests/functional/v2/test_images.py
new file mode 100644
index 0000000..bbaa052
--- /dev/null
+++ b/glance/tests/functional/v2/test_images.py
@@ -0,0 +1,468 @@
+# 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 json
+
+import requests
+
+from glance.tests import functional
+from glance.common import utils
+
+
+TENANT1 = utils.generate_uuid()
+TENANT2 = utils.generate_uuid()
+TENANT3 = utils.generate_uuid()
+TENANT4 = utils.generate_uuid()
+
+
+class TestImages(functional.FunctionalTest):
+
+    def setUp(self):
+        super(TestImages, self).setUp()
+        self.cleanup()
+        self.api_server.deployment_flavor = 'noauth'
+        self.start_servers(**self.__dict__.copy())
+
+    def _url(self, path):
+        return 'http://0.0.0.0:%d/v2%s' % (self.api_port, path)
+
+    def _headers(self, custom_headers=None):
+        base_headers = {
+            'X-Identity-Status': 'Confirmed',
+            'X-Auth-Token': '932c5c84-02ac-4fe5-a9ba-620af0e2bb96',
+            'X-User-Id': 'f9a41d13-0c13-47e9-bee2-ce4e8bfe958e',
+            'X-Tenant-Id': TENANT1,
+            'X-Roles': 'member',
+        }
+        base_headers.update(custom_headers or {})
+        return base_headers
+
+    def test_image_lifecycle(self):
+        # Image list should be empty
+        path = self._url('/images')
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(0, len(images))
+
+        # Create an image
+        path = self._url('/images')
+        headers = self._headers({'content-type': 'application/json'})
+        data = json.dumps({'name': 'image-1'})
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(200, response.status_code)
+        image_location_header = response.headers['Location']
+
+        # Returned image entity should have a generated id
+        image = json.loads(response.text)['image']
+        image_id = image['id']
+
+        # Image list should now have one entry
+        path = self._url('/images')
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(1, len(images))
+        self.assertEqual(images[0]['id'], image_id)
+
+        # Get the image using the returned Location header
+        response = requests.get(image_location_header, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        image = json.loads(response.text)['image']
+        self.assertEqual(image_id, image['id'])
+
+        # The image should be mutable
+        path = self._url('/images/%s' % image_id)
+        data = json.dumps({'name': 'image-2'})
+        response = requests.put(path, headers=self._headers(), data=data)
+        self.assertEqual(200, response.status_code)
+
+        # Returned image entity should reflect the changes
+        image = json.loads(response.text)['image']
+        self.assertEqual('image-2', image['name'])
+
+        # Updates should persist across requests
+        path = self._url('/images/%s' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        image = json.loads(response.text)['image']
+        self.assertEqual(image_id, image['id'])
+        self.assertEqual('image-2', image['name'])
+
+        # Try to download data before its uploaded
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers()
+        response = requests.get(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        # Upload some image data
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers({'Content-Type': 'application/octet-stream'})
+        response = requests.put(path, headers=headers, data='ZZZZZ')
+        self.assertEqual(200, response.status_code)
+
+        # Try to download the data that was just uploaded
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers()
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(response.text, 'ZZZZZ')
+
+        # Deletion should work
+        path = self._url('/images/%s' % image_id)
+        response = requests.delete(path, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # This image should be no longer be directly accessible
+        path = self._url('/images/%s' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(404, response.status_code)
+
+        # And neither should its data
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers()
+        response = requests.get(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        # Image list should now be empty
+        path = self._url('/images')
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(0, len(images))
+
+        self.stop_servers()
+
+    def test_upload_duplicate_data(self):
+        # Create an image
+        path = self._url('/images')
+        headers = self._headers({'content-type': 'application/json'})
+        data = json.dumps({'name': 'image-1'})
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(200, response.status_code)
+
+        # Returned image entity should have a generated id
+        image = json.loads(response.text)['image']
+        image_id = image['id']
+
+        # Upload some image data
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers({'Content-Type': 'application/octet-stream'})
+        response = requests.put(path, headers=headers, data='ZZZZZ')
+        self.assertEqual(200, response.status_code)
+
+        # Uploading duplicate data should be rejected with a 409
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers({'Content-Type': 'application/octet-stream'})
+        response = requests.put(path, headers=headers, data='XXX')
+        self.assertEqual(409, response.status_code)
+
+        # Data should not have been overwritten
+        path = self._url('/images/%s/file' % image_id)
+        headers = self._headers()
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(response.text, 'ZZZZZ')
+
+        self.stop_servers()
+
+    def test_permissions(self):
+        # Create an image that belongs to TENANT1
+        path = self._url('/images')
+        headers = self._headers({'Content-Type': 'application/json'})
+        data = json.dumps({'name': 'image-1'})
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(200, response.status_code)
+        image_id = json.loads(response.text)['image']['id']
+
+        # TENANT1 should see the image in their list
+        path = self._url('/images')
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(image_id, images[0]['id'])
+
+        # TENANT1 should be able to access the image directly
+        path = self._url('/images/%s' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+
+        # TENANT2 should not see the image in their list
+        path = self._url('/images')
+        headers = self._headers({'X-Tenant-Id': TENANT2})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(0, len(images))
+
+        # TENANT2 should not be able to access the image directly
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT2})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        # TENANT2 should not be able to modify the image, either
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({
+            'Content-Type': 'application/json',
+            'X-Tenant-Id': TENANT2,
+        })
+        data = json.dumps({'name': 'image-2'})
+        response = requests.put(path, headers=headers, data=data)
+        self.assertEqual(404, response.status_code)
+
+        # TENANT2 should not be able to delete the image, either
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT2})
+        response = requests.delete(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        # Share the image with TENANT2
+        path = self._url('/images/%s/access' % image_id)
+        data = json.dumps({'tenant_id': TENANT2, 'can_share': False})
+        request_headers = {'Content-Type': 'application/json'}
+        headers = self._headers(request_headers)
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(201, response.status_code)
+
+        # TENANT2 should see the image in their list
+        path = self._url('/images')
+        headers = self._headers({'X-Tenant-Id': TENANT2})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(image_id, images[0]['id'])
+
+        # TENANT2 should be able to access the image directly
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT2})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+
+        # TENANT2 should not be able to modify the image
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({
+            'Content-Type': 'application/json',
+            'X-Tenant-Id': TENANT2,
+        })
+        data = json.dumps({'name': 'image-2'})
+        response = requests.put(path, headers=headers, data=data)
+        self.assertEqual(404, response.status_code)
+
+        # TENANT2 should not be able to delete the image, either
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT2})
+        response = requests.delete(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        # As an unshared tenant, TENANT3 should not have access to the image
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT3})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        # Publicize the image as an admin of TENANT1
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({
+            'Content-Type': 'application/json',
+            'X-Roles': 'admin',
+        })
+        data = json.dumps({'visibility': 'public'})
+        response = requests.put(path, headers=headers, data=data)
+        self.assertEqual(200, response.status_code)
+
+        # TENANT3 should now see the image in their list
+        path = self._url('/images')
+        headers = self._headers({'X-Tenant-Id': TENANT3})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+        images = json.loads(response.text)['images']
+        self.assertEqual(image_id, images[0]['id'])
+
+        # TENANT3 should also be able to access the image directly
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT3})
+        response = requests.get(path, headers=headers)
+        self.assertEqual(200, response.status_code)
+
+        # TENANT3 still should not be able to modify the image
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({
+            'Content-Type': 'application/json',
+            'X-Tenant-Id': TENANT3,
+        })
+        data = json.dumps({'name': 'image-2'})
+        response = requests.put(path, headers=headers, data=data)
+        self.assertEqual(404, response.status_code)
+
+        # TENANT3 should not be able to delete the image, either
+        path = self._url('/images/%s' % image_id)
+        headers = self._headers({'X-Tenant-Id': TENANT3})
+        response = requests.delete(path, headers=headers)
+        self.assertEqual(404, response.status_code)
+
+        self.stop_servers()
+
+    def test_access_lifecycle(self):
+        # Create an image for our tests
+        path = self._url('/images')
+        headers = self._headers({'Content-Type': 'application/json'})
+        data = json.dumps({'name': 'image-1'})
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(200, response.status_code)
+        image_id = json.loads(response.text)['image']['id']
+
+        # Image acccess list should be empty
+        path = self._url('/images/%s/access' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        access_records = json.loads(response.text)['access_records']
+        self.assertEqual(0, len(access_records))
+
+        # Other tenants shouldn't be able to share by default, and shouldn't
+        # even know the image exists
+        path = self._url('/images/%s/access' % image_id)
+        data = json.dumps({'tenant_id': TENANT3, 'can_share': False})
+        request_headers = {
+            'Content-Type': 'application/json',
+            'X-Tenant-Id': TENANT2,
+        }
+        headers = self._headers(request_headers)
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(404, response.status_code)
+
+        # Share the image with another tenant
+        path = self._url('/images/%s/access' % image_id)
+        data = json.dumps({'tenant_id': TENANT2, 'can_share': True})
+        headers = self._headers({'Content-Type': 'application/json'})
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(201, response.status_code)
+        access_location = response.headers['Location']
+
+        # Ensure the access record was actually created
+        response = requests.get(access_location, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+
+        # Make sure the sharee can further share the image
+        path = self._url('/images/%s/access' % image_id)
+        data = json.dumps({'tenant_id': TENANT3, 'can_share': False})
+        request_headers = {
+            'Content-Type': 'application/json',
+            'X-Tenant-Id': TENANT2,
+        }
+        headers = self._headers(request_headers)
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(201, response.status_code)
+        access_location = response.headers['Location']
+
+        # Ensure the access record was actually created
+        response = requests.get(access_location, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+
+        # The third tenant should not be able to share it further
+        path = self._url('/images/%s/access' % image_id)
+        data = json.dumps({'tenant_id': TENANT4, 'can_share': False})
+        request_headers = {
+            'Content-Type': 'application/json',
+            'X-Tenant-Id': TENANT3,
+        }
+        headers = self._headers(request_headers)
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(403, response.status_code)
+
+        # Image acccess list should now contain 2 entries
+        path = self._url('/images/%s/access' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        access_records = json.loads(response.text)['access_records']
+        self.assertEqual(2, len(access_records))
+
+        # Delete an access record
+        response = requests.delete(access_location, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # Ensure the access record was actually deleted
+        response = requests.get(access_location, headers=self._headers())
+        self.assertEqual(404, response.status_code)
+
+        # Image acccess list should now contain 1 entry
+        path = self._url('/images/%s/access' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        access_records = json.loads(response.text)['access_records']
+        self.assertEqual(1, len(access_records))
+
+        self.stop_servers()
+
+    def test_tag_lifecycle(self):
+        # Create an image for our tests
+        path = self._url('/images')
+        headers = self._headers({'Content-Type': 'application/json'})
+        data = json.dumps({'name': 'image-1'})
+        response = requests.post(path, headers=headers, data=data)
+        self.assertEqual(200, response.status_code)
+        image_id = json.loads(response.text)['image']['id']
+
+        # List of image tags should be empty
+        path = self._url('/images/%s/tags' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        tags = json.loads(response.text)
+        self.assertEqual([], tags)
+
+        # Create a tag
+        path = self._url('/images/%s/tags/sniff' % image_id)
+        response = requests.put(path, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # List should now have an entry
+        path = self._url('/images/%s/tags' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        tags = json.loads(response.text)
+        self.assertEqual(['sniff'], tags)
+
+        # Create a more complex tag
+        path = self._url('/images/%s/tags/someone%%40example.com' % image_id)
+        response = requests.put(path, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # List should reflect our new tag
+        path = self._url('/images/%s/tags' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        tags = json.loads(response.text)
+        self.assertEqual(['sniff', 'someone@example.com'], tags)
+
+        # The tag should be deletable
+        path = self._url('/images/%s/tags/someone%%40example.com' % image_id)
+        response = requests.delete(path, headers=self._headers())
+        self.assertEqual(204, response.status_code)
+
+        # List should reflect the deletion
+        path = self._url('/images/%s/tags' % image_id)
+        response = requests.get(path, headers=self._headers())
+        self.assertEqual(200, response.status_code)
+        tags = json.loads(response.text)
+        self.assertEqual(['sniff'], tags)
+
+        # Deleting the same tag should return a 404
+        path = self._url('/images/%s/tags/someonei%%40example.com' % image_id)
+        response = requests.delete(path, headers=self._headers())
+        self.assertEqual(404, response.status_code)
+
+        self.stop_servers()