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 <traceback object at 0x1918d40>
+
+ :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()