From b46563eda7d2f1ca00e43aa33235df9a572039a3 Mon Sep 17 00:00:00 2001 From: "Mark J. Washenberger" Date: Wed, 14 Nov 2012 11:37:47 -0800 Subject: [PATCH] Add an image repo to encapsulate db api access This patch adds glance.db.ImageRepo which provides a Repository-like abstraction over image persistence. In the process of testing, a few additional changes to the simple and sqlalchemy db apis were needed. Partially implements bp:glance-domain-logic-layer Change-Id: Ie35e427aa8fc7f3bece8d4d383afde4cc02b2096 --- glance/db/__init__.py | 122 ++++++++++++++++++++++++ glance/db/simple/api.py | 16 +++- glance/db/sqlalchemy/api.py | 2 +- glance/tests/unit/test_db.py | 176 +++++++++++++++++++++++++++++++++++ 4 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 glance/tests/unit/test_db.py diff --git a/glance/db/__init__.py b/glance/db/__init__.py index cbc5702b38..54165baef4 100644 --- a/glance/db/__init__.py +++ b/glance/db/__init__.py @@ -17,6 +17,8 @@ # License for the specific language governing permissions and limitations # under the License. +from glance.common import exception +import glance.domain from glance.openstack.common import cfg from glance.openstack.common import importutils @@ -56,3 +58,123 @@ IMAGE_ATTRS = BASE_MODEL_ATTRS | set(['name', 'status', 'size', 'min_disk', 'min_ram', 'is_public', 'location', 'checksum', 'owner', 'protected']) + + +class ImageRepo(object): + + def __init__(self, context, db_api): + self.context = context + self.db_api = db_api + + def get(self, image_id): + try: + db_api_image = dict(self.db_api.image_get(self.context, image_id)) + assert not db_api_image['deleted'] + except (exception.NotFound, exception.Forbidden, AssertionError): + raise exception.NotFound(image_id=image_id) + tags = self.db_api.image_tag_get_all(self.context, image_id) + image = self._format_image_from_db(db_api_image, tags) + return image + + def list(self, marker=None, limit=None, sort_key='created_at', + sort_dir='desc', filters=None): + db_filters = self._translate_filters(filters) + db_api_images = self.db_api.image_get_all( + self.context, filters=db_filters, marker=marker, limit=limit, + sort_key=sort_key, sort_dir=sort_dir) + images = [] + for db_api_image in db_api_images: + tags = self.db_api.image_tag_get_all(self.context, + db_api_image['id']) + image = self._format_image_from_db(dict(db_api_image), tags) + images.append(image) + return images + + def _translate_filters(self, filters): + db_filters = {} + if filters is None: + return None + for key, value in filters.iteritems(): + if key == 'visibility': + db_filters['is_public'] = value == 'public' + else: + db_filters[key] = value + return db_filters + + def _format_image_from_db(self, db_image, db_tags): + visibility = 'public' if db_image['is_public'] else 'private' + properties = {} + for prop in db_image.pop('properties'): + # NOTE(markwash) db api requires us to filter deleted + if not prop['deleted']: + properties[prop['name']] = prop['value'] + return glance.domain.Image( + image_id=db_image['id'], + name=db_image['name'], + status=db_image['status'], + created_at=db_image['created_at'], + updated_at=db_image['updated_at'], + visibility=visibility, + min_disk=db_image['min_disk'], + min_ram=db_image['min_ram'], + protected=db_image['protected'], + location=db_image['location'], + checksum=db_image['checksum'], + owner=db_image['owner'], + disk_format=db_image['disk_format'], + container_format=db_image['container_format'], + size=db_image['size'], + extra_properties=properties, + tags=db_tags + ) + + def _format_image_to_db(self, image): + return { + 'id': image.image_id, + 'name': image.name, + 'status': image.status, + 'created_at': image.created_at, + 'min_disk': image.min_disk, + 'min_ram': image.min_ram, + 'protected': image.protected, + 'location': image.location, + 'checksum': image.checksum, + 'owner': image.owner, + 'disk_format': image.disk_format, + 'container_format': image.container_format, + 'size': image.size, + 'is_public': image.visibility == 'public', + 'properties': dict(image.extra_properties), + } + + def add(self, image): + image_values = self._format_image_to_db(image) + new_values = self.db_api.image_create(self.context, image_values) + self.db_api.image_tag_set_all(self.context, + image.image_id, image.tags) + image.created_at = new_values['created_at'] + image.updated_at = new_values['updated_at'] + + def save(self, image): + image_values = self._format_image_to_db(image) + try: + new_values = self.db_api.image_update(self.context, + image.image_id, + image_values, + purge_props=True) + except (exception.NotFound, exception.Forbidden): + raise exception.NotFound(image_id=image.image_id) + self.db_api.image_tag_set_all(self.context, image.image_id, + image.tags) + image.updated_at = new_values['updated_at'] + + def remove(self, image): + image_values = self._format_image_to_db(image) + try: + self.db_api.image_update(self.context, image.image_id, + image_values, purge_props=True) + except (exception.NotFound, exception.Forbidden): + raise exception.NotFound(image_id=image.image_id) + # NOTE(markwash): don't update tags? + new_values = self.db_api.image_destroy(self.context, image.image_id) + image.updated_at = new_values['updated_at'] diff --git a/glance/db/simple/api.py b/glance/db/simple/api.py index 791aa4f049..179766d3aa 100644 --- a/glance/db/simple/api.py +++ b/glance/db/simple/api.py @@ -167,7 +167,7 @@ def _do_pagination(context, images, marker, limit, show_deleted): start = 0 else: # Check that the image is accessible - image_get(context, marker, force_show_deleted=show_deleted) + _image_get(context, marker, force_show_deleted=show_deleted) for i, image in enumerate(images): if image['id'] == marker: @@ -191,8 +191,7 @@ def _sort_images(images, sort_key, sort_dir): return images -@log_call -def image_get(context, image_id, session=None, force_show_deleted=False): +def _image_get(context, image_id, force_show_deleted=False): try: image = DATA['images'][image_id] except KeyError: @@ -210,6 +209,12 @@ def image_get(context, image_id, session=None, force_show_deleted=False): return image +@log_call +def image_get(context, image_id, session=None, force_show_deleted=False): + image = _image_get(context, image_id, force_show_deleted) + return copy.deepcopy(image) + + @log_call def image_get_all(context, filters=None, marker=None, limit=None, sort_key='created_at', sort_dir='desc'): @@ -224,7 +229,7 @@ def image_get_all(context, filters=None, marker=None, limit=None, @log_call def image_property_create(context, values): - image = image_get(context, values['image_id']) + image = _image_get(context, values['image_id']) prop = _image_property_format(values['image_id'], values['name'], values['value']) @@ -351,13 +356,14 @@ def image_destroy(context, image_id): try: DATA['images'][image_id]['deleted'] = True DATA['images'][image_id]['deleted_at'] = timeutils.utcnow() + return copy.deepcopy(DATA['images'][image_id]) except KeyError: raise exception.NotFound() @log_call def image_tag_get_all(context, image_id): - image_get(context, image_id) + _image_get(context, image_id) return DATA['tags'].get(image_id, []) diff --git a/glance/db/sqlalchemy/api.py b/glance/db/sqlalchemy/api.py index 94a7e93f44..7bfd8823ae 100644 --- a/glance/db/sqlalchemy/api.py +++ b/glance/db/sqlalchemy/api.py @@ -579,7 +579,7 @@ def _image_update(context, values, image_id, purge_props=False): # Perform authorization check check_mutate_authorization(context, image_ref) else: - if 'size' in values: + if values.get('size') is not None: values['size'] = int(values['size']) if 'min_ram' in values: diff --git a/glance/tests/unit/test_db.py b/glance/tests/unit/test_db.py new file mode 100644 index 0000000000..3eec13a9b2 --- /dev/null +++ b/glance/tests/unit/test_db.py @@ -0,0 +1,176 @@ +# 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 glance.context +from glance.common import exception +import glance.db +from glance.openstack.common import uuidutils +import glance.tests.unit.utils as unit_test_utils +import glance.tests.utils as test_utils + + +UUID1 = 'c80a1a6c-bd1f-41c5-90ee-81afedb1d58d' +UUID2 = 'a85abd86-55b3-4d5b-b0b4-5d0a6e6042fc' +UUID3 = '971ec09a-8067-4bc8-a91f-ae3557f1c4c7' +UUID4 = '6bbe7cc2-eae7-4c0f-b50d-a7160b0c6a86' + +TENANT1 = '6838eb7b-6ded-434a-882c-b344c77fe8df' +TENANT2 = '2c014f32-55eb-467d-8fcb-4bd706012f81' +TENANT3 = '5a3e60e8-cfa9-4a9e-a90a-62b42cea92b8' +TENANT4 = 'c6c87f25-8a94-47ed-8c83-053c25f42df4' + +USER1 = '54492ba0-f4df-4e4e-be62-27f4d76b29cf' + + +def _db_fixture(id, **kwargs): + obj = { + 'id': id, + 'name': None, + 'is_public': False, + 'properties': {}, + 'checksum': None, + 'owner': None, + 'status': 'queued', + 'tags': [], + 'size': None, + 'location': None, + 'protected': False, + 'disk_format': None, + 'container_format': None, + 'deleted': False, + 'min_ram': None, + 'min_disk': None, + } + obj.update(kwargs) + return obj + + +class TestImageRepo(test_utils.BaseTestCase): + + def setUp(self): + self.db = unit_test_utils.FakeDB() + self.db.reset() + self.context = glance.context.RequestContext( + user=USER1, tenant=TENANT1) + self.image_repo = glance.db.ImageRepo(self.context, self.db) + self.image_factory = glance.domain.ImageFactory() + self._create_images() + super(TestImageRepo, self).setUp() + + def _create_images(self): + self.db.reset() + self.images = [ + _db_fixture(UUID1, owner=TENANT1, name='1', size=256, + is_public=True, status='active'), + _db_fixture(UUID2, owner=TENANT1, name='2', + size=512, is_public=False), + _db_fixture(UUID3, owner=TENANT3, name='3', + size=1024, is_public=True), + _db_fixture(UUID4, owner=TENANT4, name='4', size=2048), + ] + [self.db.image_create(None, image) for image in self.images] + + self.db.image_tag_set_all(None, UUID1, ['ping', 'pong']) + + def test_get(self): + image = self.image_repo.get(UUID1) + self.assertEquals(image.image_id, UUID1) + self.assertEquals(image.name, '1') + self.assertEquals(image.tags, set(['ping', 'pong'])) + self.assertEquals(image.visibility, 'public') + self.assertEquals(image.status, 'active') + self.assertEquals(image.size, 256) + self.assertEquals(image.owner, TENANT1) + + def test_get_not_found(self): + self.assertRaises(exception.NotFound, self.image_repo.get, + uuidutils.generate_uuid()) + + def test_get_forbidden(self): + self.assertRaises(exception.NotFound, self.image_repo.get, UUID4) + + def test_list(self): + images = self.image_repo.list() + image_ids = set([i.image_id for i in images]) + self.assertEqual(set([UUID1, UUID2, UUID3]), image_ids) + + def test_list_with_marker(self): + full_images = self.image_repo.list() + full_ids = [i.image_id for i in full_images] + marked_images = self.image_repo.list(marker=full_ids[0]) + actual_ids = [i.image_id for i in marked_images] + self.assertEqual(actual_ids, full_ids[1:]) + + def test_list_with_last_marker(self): + images = self.image_repo.list() + marked_images = self.image_repo.list(marker=images[-1].image_id) + self.assertEqual(len(marked_images), 0) + + def test_limited_list(self): + limited_images = self.image_repo.list(limit=2) + self.assertEqual(len(limited_images), 2) + + def test_list_with_marker_and_limit(self): + full_images = self.image_repo.list() + full_ids = [i.image_id for i in full_images] + marked_images = self.image_repo.list(marker=full_ids[0], limit=1) + actual_ids = [i.image_id for i in marked_images] + self.assertEqual(actual_ids, full_ids[1:2]) + + def test_list_private_images(self): + filters = {'visibility': 'private'} + images = self.image_repo.list(filters=filters) + image_ids = set([i.image_id for i in images]) + self.assertEqual(set([UUID2]), image_ids) + + def test_list_public_images(self): + filters = {'visibility': 'public'} + images = self.image_repo.list(filters=filters) + image_ids = set([i.image_id for i in images]) + self.assertEqual(set([UUID1, UUID3]), image_ids) + + def test_sorted_list(self): + images = self.image_repo.list(sort_key='size', sort_dir='asc') + image_ids = [i.image_id for i in images] + self.assertEqual([UUID1, UUID2, UUID3], image_ids) + + def test_add_image(self): + image = self.image_factory.new_image(name='added image') + self.assertEqual(image.updated_at, image.created_at) + self.image_repo.add(image) + self.assertTrue(image.updated_at > image.created_at) + retreived_image = self.image_repo.get(image.image_id) + self.assertEqual(retreived_image.name, 'added image') + self.assertEqual(retreived_image.updated_at, image.updated_at) + + def test_save_image(self): + image = self.image_repo.get(UUID1) + original_update_time = image.updated_at + image.name = 'foo' + image.tags = ['king', 'kong'] + self.image_repo.save(image) + current_update_time = image.updated_at + self.assertTrue(current_update_time > original_update_time) + image = self.image_repo.get(UUID1) + self.assertEqual(image.name, 'foo') + self.assertEqual(image.tags, set(['king', 'kong'])) + self.assertEqual(image.updated_at, current_update_time) + + def test_remove_image(self): + image = self.image_repo.get(UUID1) + previous_update_time = image.updated_at + self.image_repo.remove(image) + self.assertTrue(image.updated_at > previous_update_time) + self.assertRaises(exception.NotFound, self.image_repo.get, UUID1)