From d44afa05c270335d6c9812913f35d75669f0886a Mon Sep 17 00:00:00 2001 From: Eoghan Glynn 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 '+' --> 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()