diff --git a/glance/tests/functional/__init__.py b/glance/tests/functional/__init__.py index 7845071c89..8fe405701b 100644 --- a/glance/tests/functional/__init__.py +++ b/glance/tests/functional/__init__.py @@ -34,9 +34,13 @@ import socket import subprocess import sys import tempfile +import textwrap import time +from unittest import mock +import uuid import fixtures +import glance_store from os_win import utilsfactory as os_win_utilsfactory from oslo_config import cfg from oslo_serialization import jsonutils @@ -44,8 +48,11 @@ from oslo_serialization import jsonutils from six.moves import range import six.moves.urllib.parse as urlparse import testtools +import webob +from glance.common import config from glance.common import utils +from glance.common import wsgi from glance.db.sqlalchemy import api as db_api from glance import tests as glance_tests from glance.tests import utils as test_utils @@ -1467,3 +1474,209 @@ class MultipleBackendFunctionalTest(test_utils.BaseTestCase): self._attached_server_logs.append(s.log_file) self.addDetail( s.server_name, testtools.content.text_content(s.dump_log())) + + +class SynchronousAPIBase(test_utils.BaseTestCase): + """A base class that provides synchronous calling into the API. + + This provides a way to directly call into the API WSGI stack + without starting a separate server, and with a simple paste + pipeline. Configured with multi-store and a real database. + + This differs from the FunctionalTest lineage above in that they + start a full copy of the API server as a separate process, whereas + this calls directly into the WSGI stack. This test base is + appropriate for situations where you need to be able to mock the + state of the world (i.e. warp time, or inject errors) but should + not be used for happy-path testing where FunctionalTest provides + more isolation. + + To use this, inherit and run start_server() before you are ready + to make API calls (either in your setUp() or per-test if you need + to change config or mocking). + + Once started, use the api_get(), api_put(), api_post(), and + api_delete() methods to make calls to the API. + + """ + + TENANT = str(uuid.uuid4()) + + @mock.patch('oslo_db.sqlalchemy.enginefacade.writer.get_engine') + def setup_database(self, mock_get_engine): + """Configure and prepare a fresh sqlite database.""" + db_file = 'sqlite:///%s/test.db' % self.test_dir + self.config(connection=db_file, group='database') + + # NOTE(danms): Make sure that we clear the current global + # database configuration, provision a temporary database file, + # and run migrations with our configuration to define the + # schema there. + db_api.clear_db_env() + engine = db_api.get_engine() + mock_get_engine.return_value = engine + with mock.patch('logging.config'): + # NOTE(danms): The alembic config in the env module will break our + # BaseTestCase logging setup. So mock that out to prevent it while + # we db_sync. + test_utils.db_sync(engine=engine) + + def setup_simple_paste(self): + """Setup a very simple no-auth paste pipeline. + + This configures the API to be very direct, including only the + middleware absolutely required for consistent API calls. + """ + self.paste_config = os.path.join(self.test_dir, 'glance-api-paste.ini') + with open(self.paste_config, 'w') as f: + f.write(textwrap.dedent(""" + [filter:context] + paste.filter_factory = glance.api.middleware.context:\ + ContextMiddleware.factory + [filter:fakeauth] + paste.filter_factory = glance.tests.utils:\ + FakeAuthMiddleware.factory + [pipeline:glance-api] + pipeline = context rootapp + [composite:rootapp] + paste.composite_factory = glance.api:root_app_factory + /v2: apiv2app + [app:apiv2app] + paste.app_factory = glance.api.v2.router:API.factory + """)) + + def _store_dir(self, store): + return os.path.join(self.test_dir, store) + + def setup_stores(self): + """Configures multiple backend stores. + + This configures the API with two file-backed stores (store1 + and store2) as well as a os_glance_staging_store for imports. + + """ + self.config(enabled_backends={'store1': 'file', 'store2': 'file'}) + glance_store.register_store_opts(CONF, + reserved_stores=wsgi.RESERVED_STORES) + self.config(default_backend='store1', + group='glance_store') + self.config(filesystem_store_datadir=self._store_dir('store1'), + group='store1') + self.config(filesystem_store_datadir=self._store_dir('store2'), + group='store2') + self.config(filesystem_store_datadir=self._store_dir('staging'), + group='os_glance_staging_store') + + glance_store.create_multi_stores(CONF, + reserved_stores=wsgi.RESERVED_STORES) + glance_store.verify_store() + + def setUp(self): + super(SynchronousAPIBase, self).setUp() + + self.setup_database() + self.setup_simple_paste() + self.setup_stores() + + def start_server(self): + """Builds and "starts" the API server. + + Note that this doesn't actually "start" anything like + FunctionalTest does above, but that terminology is used here + to make it seem like the same sort of pattern. + """ + config.set_config_defaults() + self.api = config.load_paste_app('glance-api', + conf_file=self.paste_config) + + 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': self.TENANT, + 'Content-Type': 'application/json', + 'X-Roles': 'admin', + } + base_headers.update(custom_headers or {}) + return base_headers + + def api_request(self, method, url, headers=None, data=None, + json=None, body_file=None): + """Perform a request against the API. + + NOTE: Most code should use api_get(), api_post(), api_put(), + or api_delete() instead! + + :param method: The HTTP method to use (i.e. GET, POST, etc) + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :param data: Optional bytes data payload to send (overrides @json) + :param json: Optional dict structure to be jsonified and sent as + the payload (mutually exclusive with @data) + :param body_file: Optional io.IOBase to provide as the input data + stream for the request (overrides @data) + :returns: A webob.Response object + """ + headers = self._headers(headers) + req = webob.Request.blank(url, method=method, + headers=headers) + if json and not data: + data = jsonutils.dumps(json).encode() + if data and not body_file: + req.body = data + elif body_file: + req.body_file = body_file + return self.api(req) + + def api_get(self, url, headers=None): + """Perform a GET request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :returns: A webob.Response object + """ + return self.api_request('GET', url, headers=headers) + + def api_post(self, url, headers=None, data=None, json=None, + body_file=None): + """Perform a POST request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :param data: Optional bytes data payload to send (overrides @json) + :param json: Optional dict structure to be jsonified and sent as + the payload (mutually exclusive with @data) + :param body_file: Optional io.IOBase to provide as the input data + stream for the request (overrides @data) + :returns: A webob.Response object + """ + return self.api_request('POST', url, headers=headers, + data=data, json=json, + body_file=body_file) + + def api_put(self, url, headers=None, data=None, json=None, body_file=None): + """Perform a PUT request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :param data: Optional bytes data payload to send (overrides @json, + mutually exclusive with body_file) + :param json: Optional dict structure to be jsonified and sent as + the payload (mutually exclusive with @data) + :param body_file: Optional io.IOBase to provide as the input data + stream for the request (overrides @data) + :returns: A webob.Response object + """ + return self.api_request('PUT', url, headers=headers, + data=data, json=json, + body_file=body_file) + + def api_delete(self, url, headers=None): + """Perform a DELETE request against the API. + + :param url: The *path* part of the URL to call (i.e. /v2/images) + :param headers: Optional updates to the default set of headers + :returns: A webob.Response object + """ + return self.api_request('DELETE', url, heaers=headers) diff --git a/glance/tests/functional/v2/test_images_import_locking.py b/glance/tests/functional/v2/test_images_import_locking.py index d759e47d4f..6e9a45f80a 100644 --- a/glance/tests/functional/v2/test_images_import_locking.py +++ b/glance/tests/functional/v2/test_images_import_locking.py @@ -14,151 +14,19 @@ # under the License. import datetime -import os from testtools import content as ttc -import textwrap import time from unittest import mock -import uuid -from oslo_config import cfg -from oslo_log import log as logging from oslo_serialization import jsonutils from oslo_utils import fixture as time_fixture from oslo_utils import units -import webob -from glance.common import config -from glance.common import wsgi -import glance.db.sqlalchemy.api +from glance.tests import functional from glance.tests import utils as test_utils -import glance_store - -LOG = logging.getLogger(__name__) -TENANT1 = str(uuid.uuid4()) -CONF = cfg.CONF -class SynchronousAPIBase(test_utils.BaseTestCase): - """A test base class that provides synchronous calling into the API - without starting a separate server, and with a simple paste - pipeline. Configured with multi-store and a real database. - """ - - @mock.patch('oslo_db.sqlalchemy.enginefacade.writer.get_engine') - def setup_database(self, mock_get_engine): - db_file = 'sqlite:///%s/test-%s.db' % (self.test_dir, - uuid.uuid4()) - self.config(connection=db_file, group='database') - - # NOTE(danms): Make sure that we clear the current global - # database configuration, provision a temporary database file, - # and run migrations with our configuration to define the - # schema there. - glance.db.sqlalchemy.api.clear_db_env() - engine = glance.db.sqlalchemy.api.get_engine() - mock_get_engine.return_value = engine - with mock.patch('logging.config'): - # NOTE(danms): The alembic config in the env module will break our - # BaseTestCase logging setup. So mock that out to prevent it while - # we db_sync. - test_utils.db_sync(engine=engine) - - def setup_simple_paste(self): - self.paste_config = os.path.join(self.test_dir, 'glance-api-paste.ini') - with open(self.paste_config, 'w') as f: - f.write(textwrap.dedent(""" - [filter:context] - paste.filter_factory = glance.api.middleware.context:\ - ContextMiddleware.factory - [filter:fakeauth] - paste.filter_factory = glance.tests.utils:\ - FakeAuthMiddleware.factory - [pipeline:glance-api] - pipeline = context rootapp - [composite:rootapp] - paste.composite_factory = glance.api:root_app_factory - /v2: apiv2app - [app:apiv2app] - paste.app_factory = glance.api.v2.router:API.factory - """)) - - def _store_dir(self, store): - return os.path.join(self.test_dir, store) - - def setup_stores(self): - self.config(enabled_backends={'store1': 'file', 'store2': 'file'}) - glance_store.register_store_opts(CONF, - reserved_stores=wsgi.RESERVED_STORES) - self.config(default_backend='store1', - group='glance_store') - self.config(filesystem_store_datadir=self._store_dir('store1'), - group='store1') - self.config(filesystem_store_datadir=self._store_dir('store2'), - group='store2') - self.config(filesystem_store_datadir=self._store_dir('staging'), - group='os_glance_staging_store') - - glance_store.create_multi_stores(CONF, - reserved_stores=wsgi.RESERVED_STORES) - glance_store.verify_store() - - def setUp(self): - super(SynchronousAPIBase, self).setUp() - - self.setup_database() - self.setup_simple_paste() - self.setup_stores() - - def start_server(self): - config.set_config_defaults() - self.api = config.load_paste_app('glance-api', - conf_file=self.paste_config) - - 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, - 'Content-Type': 'application/json', - 'X-Roles': 'admin', - } - base_headers.update(custom_headers or {}) - return base_headers - - def api_get(self, url, headers=None): - headers = self._headers(headers) - req = webob.Request.blank(url, method='GET', - headers=headers) - return self.api(req) - - def api_post(self, url, data=None, json=None, headers=None): - headers = self._headers(headers) - req = webob.Request.blank(url, method='POST', - headers=headers) - if json and not data: - data = jsonutils.dumps(json).encode() - headers['Content-Type'] = 'application/json' - if data: - req.body = data - LOG.debug(req.as_bytes()) - return self.api(req) - - def api_put(self, url, data=None, json=None, headers=None, body_file=None): - headers = self._headers(headers) - req = webob.Request.blank(url, method='PUT', - headers=headers) - if json and not data: - data = jsonutils.dumps(json).encode() - if data and not body_file: - req.body = data - elif body_file: - req.body_file = body_file - return self.api(req) - - -class TestImageImportLocking(SynchronousAPIBase): +class TestImageImportLocking(functional.SynchronousAPIBase): def _import_copy(self, image_id, stores): """Do an import of image_id to the given stores.""" body = {'method': {'name': 'copy-image'},