From bee8b13d12cdf2247c397a3c335b3bf4e9a26054 Mon Sep 17 00:00:00 2001 From: Pádraig Brady Date: May 21 2012 09:48:44 +0000 Subject: sync with essex stable --- diff --git a/0003-Don-t-access-the-net-while-building-docs.patch b/0003-Don-t-access-the-net-while-building-docs.patch deleted file mode 100644 index f4aba04..0000000 --- a/0003-Don-t-access-the-net-while-building-docs.patch +++ /dev/null @@ -1,25 +0,0 @@ -From 332b3d0f916561bc85eaa1167fda4821815b0a3d Mon Sep 17 00:00:00 2001 -From: =?UTF-8?q?P=C3=A1draig=20Brady?= -Date: Fri, 6 Jan 2012 17:12:54 +0000 -Subject: [PATCH] Don't access the net while building docs - -(Note this hasn't been submitted upstream) - -Change-Id: I42c6e3a5062db209a0abe00cebc04d383c79cbcb -(cherry picked from commit f2b4bb4e45afcc178200966193a7b87401c534d7) ---- - doc/source/conf.py | 1 - - 1 files changed, 0 insertions(+), 1 deletions(-) - -diff --git a/doc/source/conf.py b/doc/source/conf.py -index a6a3c35..670e4f3 100644 ---- a/doc/source/conf.py -+++ b/doc/source/conf.py -@@ -45,7 +45,6 @@ sys.path = [os.path.abspath('../../glance'), - extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.coverage', - 'sphinx.ext.ifconfig', -- 'sphinx.ext.intersphinx', - 'sphinx.ext.pngmath', - 'sphinx.ext.graphviz'] - diff --git a/0003-Fix-content-type-for-qpid-notifier.patch b/0003-Fix-content-type-for-qpid-notifier.patch new file mode 100644 index 0000000..032a37a --- /dev/null +++ b/0003-Fix-content-type-for-qpid-notifier.patch @@ -0,0 +1,63 @@ +From 5838b6390353e4c34762828684cee90410e4f8b1 Mon Sep 17 00:00:00 2001 +From: Russell Bryant +Date: Tue, 24 Apr 2012 12:24:39 -0400 +Subject: [PATCH] Fix content type for qpid notifier. + +Fix bug 980872. + +This patch fixes a regression I introduced in +2d36facf14f4eb2742ba46274e04a73b5231aece. In that patch, I adjusted the +content_type for messages sent with the qpid notifier to be +'application/json' to match a change that went into the kombu notifier. +Unfortunately, it's wrong. + +I assumed based on the kombu change that notifications were being json +encoded before being passed into the notification driver. That's not +the case. The message is a dict. So, just revert the change to set the +content_type and let Qpid encode the notification as 'amqp/map'. + +(cherry picked from commit 5bed23cbc962d3c6503f0ff93e6d1e326efbd49d) + +Change-Id: I8ba039612d9603377028ba72cb80ae89d675c741 +--- + glance/notifier/notify_qpid.py | 9 +++------ + glance/tests/unit/test_notifier.py | 2 +- + 2 files changed, 4 insertions(+), 7 deletions(-) + +diff --git a/glance/notifier/notify_qpid.py b/glance/notifier/notify_qpid.py +index d3d5514..a0535a6 100644 +--- a/glance/notifier/notify_qpid.py ++++ b/glance/notifier/notify_qpid.py +@@ -135,16 +135,13 @@ class QpidStrategy(strategy.Strategy): + return self.session.sender(address) + + def warn(self, msg): +- qpid_msg = qpid.messaging.Message(content=msg, +- content_type='application/json') ++ qpid_msg = qpid.messaging.Message(content=msg) + self.sender_warn.send(qpid_msg) + + def info(self, msg): +- qpid_msg = qpid.messaging.Message(content=msg, +- content_type='application/json') ++ qpid_msg = qpid.messaging.Message(content=msg) + self.sender_info.send(qpid_msg) + + def error(self, msg): +- qpid_msg = qpid.messaging.Message(content=msg, +- content_type='application/json') ++ qpid_msg = qpid.messaging.Message(content=msg) + self.sender_error.send(qpid_msg) +diff --git a/glance/tests/unit/test_notifier.py b/glance/tests/unit/test_notifier.py +index 0e7ff75..b952ee3 100644 +--- a/glance/tests/unit/test_notifier.py ++++ b/glance/tests/unit/test_notifier.py +@@ -316,7 +316,7 @@ class TestQpidNotifier(unittest.TestCase): + super(TestQpidNotifier, self).tearDown() + + def _test_notify(self, priority): +- test_msg = json.dumps({'a': 'b'}) ++ test_msg = {'a': 'b'} + + self.mock_connection = self.mocker.CreateMock(self.orig_connection) + self.mock_session = self.mocker.CreateMock(self.orig_session) diff --git a/0004-Omit-Content-Length-on-chunked-transfer.patch b/0004-Omit-Content-Length-on-chunked-transfer.patch new file mode 100644 index 0000000..eebc8d9 --- /dev/null +++ b/0004-Omit-Content-Length-on-chunked-transfer.patch @@ -0,0 +1,183 @@ +From 7a9e3a7dc53a20971dbd653f2061337efec136a2 Mon Sep 17 00:00:00 2001 +From: Mike Lundy +Date: Fri, 13 Apr 2012 19:53:16 -0700 +Subject: [PATCH] Omit Content-Length on chunked transfer + +Content-Length and Transfer-Encoding conflict according to the HTTP +spec. This fixes bug 981332. + +This also adds the ability to test both the sendfile-present and +sendfile-absent codepaths; the sendfile-present test will be skipped on +sendfile-absent platforms. + +[ This is backported from 223fbee49a55691504623fa691bbb3e48048d5f3 ] + +Change-Id: I20856eb51ff66fe4b7145f796a540a832c3aa4d8 +--- + glance/common/client.py | 9 +++++++-- + glance/tests/stubs.py | 29 +++++++++++++++++++++++------ + glance/tests/unit/test_clients.py | 15 +++++++++++++-- + 3 files changed, 43 insertions(+), 10 deletions(-) + +diff --git a/glance/common/client.py b/glance/common/client.py +index 7c6ae55..2e1f35c 100644 +--- a/glance/common/client.py ++++ b/glance/common/client.py +@@ -506,12 +506,17 @@ class BaseClient(object): + elif _filelike(body) or self._iterable(body): + c.putrequest(method, path) + ++ use_sendfile = self._sendable(body) ++ ++ # According to HTTP/1.1, Content-Length and Transfer-Encoding ++ # conflict. + for header, value in headers.items(): +- c.putheader(header, value) ++ if use_sendfile or header.lower() != 'content-length': ++ c.putheader(header, value) + + iter = self.image_iterator(c, headers, body) + +- if self._sendable(body): ++ if use_sendfile: + # send actual file without copying into userspace + _sendbody(c, iter) + else: +diff --git a/glance/tests/stubs.py b/glance/tests/stubs.py +index dba4d7f..776da40 100644 +--- a/glance/tests/stubs.py ++++ b/glance/tests/stubs.py +@@ -59,7 +59,7 @@ def stub_out_registry_and_store_server(stubs, base_dir): + def close(self): + return True + +- def request(self, method, url, body=None, headers={}): ++ def request(self, method, url, body=None, headers=None): + self.req = webob.Request.blank("/" + url.lstrip("/")) + self.req.method = method + if headers: +@@ -110,7 +110,8 @@ def stub_out_registry_and_store_server(stubs, base_dir): + + def __init__(self, *args, **kwargs): + self.sock = FakeSocket() +- pass ++ self.stub_force_sendfile = kwargs.get('stub_force_sendfile', ++ SENDFILE_SUPPORTED) + + def connect(self): + return True +@@ -120,7 +121,7 @@ def stub_out_registry_and_store_server(stubs, base_dir): + + def putrequest(self, method, url): + self.req = webob.Request.blank("/" + url.lstrip("/")) +- if SENDFILE_SUPPORTED: ++ if self.stub_force_sendfile: + fake_sendfile = FakeSendFile(self.req) + stubs.Set(sendfile, 'sendfile', fake_sendfile.sendfile) + self.req.method = method +@@ -129,7 +130,10 @@ def stub_out_registry_and_store_server(stubs, base_dir): + self.req.headers[key] = value + + def endheaders(self): +- pass ++ hl = [i.lower() for i in self.req.headers.keys()] ++ assert not ('content-length' in hl and ++ 'transfer-encoding' in hl), \ ++ 'Content-Length and Transfer-Encoding are mutually exclusive' + + def send(self, data): + # send() is called during chunked-transfer encoding, and +@@ -137,7 +141,7 @@ def stub_out_registry_and_store_server(stubs, base_dir): + # only write the actual data in tests. + self.req.body += data.split("\r\n")[1] + +- def request(self, method, url, body=None, headers={}): ++ def request(self, method, url, body=None, headers=None): + self.req = webob.Request.blank("/" + url.lstrip("/")) + self.req.method = method + if headers: +@@ -187,8 +191,21 @@ def stub_out_registry_and_store_server(stubs, base_dir): + for i in self.source.app_iter: + yield i + ++ def fake_sendable(self, body): ++ force = getattr(self, 'stub_force_sendfile', None) ++ if force is None: ++ return self._stub_orig_sendable(body) ++ else: ++ if force: ++ assert glance.common.client.SENDFILE_SUPPORTED ++ return force ++ + stubs.Set(glance.common.client.BaseClient, 'get_connection_type', + fake_get_connection_type) ++ setattr(glance.common.client.BaseClient, '_stub_orig_sendable', ++ glance.common.client.BaseClient._sendable) ++ stubs.Set(glance.common.client.BaseClient, '_sendable', ++ fake_sendable) + stubs.Set(glance.common.client.ImageBodyIterator, '__iter__', + fake_image_iter) + +@@ -211,7 +228,7 @@ def stub_out_registry_server(stubs, **kwargs): + def close(self): + return True + +- def request(self, method, url, body=None, headers={}): ++ def request(self, method, url, body=None, headers=None): + self.req = webob.Request.blank("/" + url.lstrip("/")) + self.req.method = method + if headers: +diff --git a/glance/tests/unit/test_clients.py b/glance/tests/unit/test_clients.py +index e8e71de..1548f48 100644 +--- a/glance/tests/unit/test_clients.py ++++ b/glance/tests/unit/test_clients.py +@@ -26,7 +26,7 @@ import stubout + import webob + + from glance import client +-from glance.common import context ++from glance.common import client as base_client + from glance.common import exception + from glance.common import utils + from glance.registry.db import api as db_api +@@ -36,6 +36,7 @@ from glance.registry import context as rcontext + from glance.tests import stubs + from glance.tests import utils as test_utils + from glance.tests.unit import base ++from glance.tests import utils as test_utils + + CONF = {'sql_connection': 'sqlite://'} + +@@ -1842,7 +1843,7 @@ class TestClient(base.IsolatedUnitTest): + for k, v in fixture.items(): + self.assertEquals(v, new_meta[k]) + +- def test_add_image_with_image_data_as_file(self): ++ def add_image_with_image_data_as_file(self, sendfile): + """Tests can add image by passing image data as file""" + fixture = {'name': 'fake public image', + 'is_public': True, +@@ -1852,6 +1853,8 @@ class TestClient(base.IsolatedUnitTest): + 'properties': {'distro': 'Ubuntu 10.04 LTS'}, + } + ++ self.client.stub_force_sendfile = sendfile ++ + image_data_fixture = r"chunk00000remainder" + + tmp_image_filepath = '/tmp/rubbish-image' +@@ -1879,6 +1882,14 @@ class TestClient(base.IsolatedUnitTest): + for k, v in fixture.items(): + self.assertEquals(v, new_meta[k]) + ++ @test_utils.skip_if(not base_client.SENDFILE_SUPPORTED, ++ 'sendfile not supported') ++ def test_add_image_with_image_data_as_file_with_sendfile(self): ++ self.add_image_with_image_data_as_file(sendfile=True) ++ ++ def test_add_image_with_image_data_as_file_without_sendfile(self): ++ self.add_image_with_image_data_as_file(sendfile=False) ++ + def _add_image_as_iterable(self): + fixture = {'name': 'fake public image', + 'is_public': True, diff --git a/0005-Fix-i18n-in-glance.notifier.notify_kombu.patch b/0005-Fix-i18n-in-glance.notifier.notify_kombu.patch new file mode 100644 index 0000000..551d557 --- /dev/null +++ b/0005-Fix-i18n-in-glance.notifier.notify_kombu.patch @@ -0,0 +1,28 @@ +From 54622953c4cc09196c7f16d8229ba438afbe626f Mon Sep 17 00:00:00 2001 +From: Brian Waldon +Date: Sun, 22 Apr 2012 22:21:53 -0700 +Subject: [PATCH] Fix i18n in glance.notifier.notify_kombu + +* Fixes bug 983829 + +Change-Id: Ibc5ec12e97e69797d1952c020c3091f42480abec +--- + glance/notifier/notify_kombu.py | 5 +++-- + 1 files changed, 3 insertions(+), 2 deletions(-) + +diff --git a/glance/notifier/notify_kombu.py b/glance/notifier/notify_kombu.py +index b3447d6..6623fdc 100644 +--- a/glance/notifier/notify_kombu.py ++++ b/glance/notifier/notify_kombu.py +@@ -166,8 +166,9 @@ class RabbitStrategy(strategy.Strategy): + + def log_failure(self, msg, priority): + """Fallback to logging when we can't send to rabbit.""" +- logger.error(_('Notification with priority %(priority)s failed; ' +- 'msg=%s') % msg) ++ message = _('Notification with priority %(priority)s failed: ' ++ 'msg=%(msg)s') ++ logger.error(message % {'msg': msg, 'priority': priority}) + + def _send_message(self, msg, routing_key): + """Send a message. Caller needs to catch exceptions for retry.""" diff --git a/0006-Don-t-access-the-net-while-building-docs.patch b/0006-Don-t-access-the-net-while-building-docs.patch new file mode 100644 index 0000000..3b5e7f1 --- /dev/null +++ b/0006-Don-t-access-the-net-while-building-docs.patch @@ -0,0 +1,25 @@ +From 6a63200908b9efd846c59a0747f10502d4d819fe Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?P=C3=A1draig=20Brady?= +Date: Fri, 6 Jan 2012 17:12:54 +0000 +Subject: [PATCH] Don't access the net while building docs + +(Note this hasn't been submitted upstream) + +Change-Id: I42c6e3a5062db209a0abe00cebc04d383c79cbcb +(cherry picked from commit f2b4bb4e45afcc178200966193a7b87401c534d7) +--- + doc/source/conf.py | 1 - + 1 files changed, 0 insertions(+), 1 deletions(-) + +diff --git a/doc/source/conf.py b/doc/source/conf.py +index a6a3c35..670e4f3 100644 +--- a/doc/source/conf.py ++++ b/doc/source/conf.py +@@ -45,7 +45,6 @@ sys.path = [os.path.abspath('../../glance'), + extensions = ['sphinx.ext.autodoc', + 'sphinx.ext.coverage', + 'sphinx.ext.ifconfig', +- 'sphinx.ext.intersphinx', + 'sphinx.ext.pngmath', + 'sphinx.ext.graphviz'] + diff --git a/0007-Support-DB-auto-create-suppression.patch b/0007-Support-DB-auto-create-suppression.patch new file mode 100644 index 0000000..8aec148 --- /dev/null +++ b/0007-Support-DB-auto-create-suppression.patch @@ -0,0 +1,2204 @@ +From f4372facffb42c5c1e02e51a4a2b34edac08cbca 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 +- + glance/registry/db/api.py | 24 +- + 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 +++++++++ + 8 files changed, 1958 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/glance/registry/db/api.py b/glance/registry/db/api.py +index 04fe2e5..9e78725 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,10 @@ 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 ++ if not opt.name in conf: ++ conf.register_opt(opt) + sql_connection = conf.sql_connection + _MAX_RETRIES = conf.sql_max_retries + _RETRY_INTERVAL = conf.sql_retry_interval +@@ -131,12 +135,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() diff --git a/openstack-glance.spec b/openstack-glance.spec index 5eebb7e..01c73b7 100644 --- a/openstack-glance.spec +++ b/openstack-glance.spec @@ -1,6 +1,6 @@ Name: openstack-glance Version: 2012.1 -Release: 5%{?dist} +Release: 6%{?dist} Summary: OpenStack Image Service Group: Applications/System @@ -17,7 +17,11 @@ Source4: openstack-glance-db-setup # Patch0001: 0001-Ensure-swift-auth-URL-includes-trailing-slash.patch Patch0002: 0002-search-for-logger-in-PATH.patch -Patch0003: 0003-Don-t-access-the-net-while-building-docs.patch +Patch0003: 0003-Fix-content-type-for-qpid-notifier.patch +Patch0004: 0004-Omit-Content-Length-on-chunked-transfer.patch +Patch0005: 0005-Fix-i18n-in-glance.notifier.notify_kombu.patch +Patch0006: 0006-Don-t-access-the-net-while-building-docs.patch +Patch0007: 0007-Support-DB-auto-create-suppression.patch BuildArch: noarch BuildRequires: python2-devel @@ -95,6 +99,10 @@ This package contains documentation files for glance. %patch0001 -p1 %patch0002 -p1 %patch0003 -p1 +%patch0004 -p1 +%patch0005 -p1 +%patch0006 -p1 +%patch0007 -p1 sed -i 's|\(sql_connection = \)sqlite:///glance.sqlite|\1mysql://glance:glance@localhost/glance|' etc/glance-registry.conf @@ -228,6 +236,9 @@ fi %doc doc/build/html %changelog +* Mon May 21 2012 Pádraig Brady - 2012.1-6 +- Sync with essex stable + * Fri May 18 2012 Alan Pevec - 2012.1-5 - Drop hard dep on python-kombu, notifications are configurable