From ea963601a5088efbe1412b1c0ff9edb6de477caf Mon Sep 17 00:00:00 2001 From: "shubham.git" Date: Sat, 15 Oct 2016 10:21:51 +0530 Subject: [PATCH] Add image endpoint This patch adds image endpoint for zun-api. As of now supports image_show() and image_create(). Change-Id: I2ef1865e21b99f3bed3a5b7c53816cfe808a2fc2 Partial-Implements: blueprint add-image-endpoint --- zun/api/controllers/types.py | 46 ++++- zun/api/controllers/v1/__init__.py | 11 + zun/api/controllers/v1/containers.py | 2 +- zun/api/controllers/v1/images.py | 188 ++++++++++++++++++ zun/api/utils.py | 12 ++ zun/common/exception.py | 8 + zun/compute/api.py | 7 + zun/compute/manager.py | 29 +++ zun/container/docker/driver.py | 11 + zun/db/api.py | 78 +++++++- .../72c6947c6636_create_table_image.py | 50 +++++ zun/db/sqlalchemy/api.py | 70 ++++++- zun/db/sqlalchemy/models.py | 18 ++ zun/objects/__init__.py | 6 +- zun/objects/image.py | 129 ++++++++++++ zun/tests/unit/api/controllers/test_root.py | 7 +- zun/tests/unit/api/controllers/test_types.py | 39 +++- .../unit/api/controllers/v1/test_images.py | 119 +++++++++++ zun/tests/unit/db/test_image.py | 132 ++++++++++++ zun/tests/unit/db/utils.py | 40 ++++ zun/tests/unit/objects/test_image.py | 101 ++++++++++ 21 files changed, 1088 insertions(+), 15 deletions(-) create mode 100644 zun/api/controllers/v1/images.py create mode 100644 zun/db/sqlalchemy/alembic/versions/72c6947c6636_create_table_image.py create mode 100644 zun/objects/image.py create mode 100644 zun/tests/unit/api/controllers/v1/test_images.py create mode 100644 zun/tests/unit/db/test_image.py create mode 100644 zun/tests/unit/objects/test_image.py diff --git a/zun/api/controllers/types.py b/zun/api/controllers/types.py index 3f68eeabc..c517196be 100644 --- a/zun/api/controllers/types.py +++ b/zun/api/controllers/types.py @@ -90,6 +90,26 @@ class NameType(String): raise exception.InvalidValue(message) +class ImageNameType(String): + type_name = 'ImageNameType' + # ImageNameType allows to be Non-None or a string matches pattern + # `[a-zA-Z0-9][a-zA-Z0-9_.-].` with minimum length is 2 and maximum length + # 255 string type. + + @classmethod + def validate(cls, value): + if value is None: + message = _('Repo/Image is mandatory. Cannot be left blank.') + raise exception.InvalidValue(message) + super(ImageNameType, cls).validate(value, min_length=2, max_length=255) + match = name_pattern.match(value) + if match: + return value + else: + message = _('%s does not match [a-zA-Z0-9][a-zA-Z0-9_.-].') % value + raise exception.InvalidValue(message) + + class Integer(object): type_name = 'Integer' @@ -251,8 +271,8 @@ class DateTime(object): type=cls.type_name) -class ContainerMemory(object): - type_name = 'ContainerMemory' +class MemoryType(object): + type_name = 'MemoryType' @classmethod def validate(cls, value, default=None): @@ -271,3 +291,25 @@ class ContainerMemory(object): in both cases""" raise exception.InvalidValue(message=message, value=value, type=cls.type_name) + + +class ImageSize(object): + type_name = 'ImageSize' + + @classmethod + def validate(cls, value, default=None): + if value is None: + return + elif value.isdigit(): + return value + elif (value.isalnum() and + value[:-1].isdigit() and value[-1] in VALID_UNITS.keys()): + return int(value[:-1]) * VALID_UNITS[value[-1]] + else: + LOG.exception(_LE('Failed to validate image size')) + message = _(""" + size must be either integer or string of below format. + memory_unit must be 'k','b','m','g' + in both cases""") + raise exception.InvalidValue(message=message, value=value, + type=cls.type_name) diff --git a/zun/api/controllers/v1/__init__.py b/zun/api/controllers/v1/__init__.py index 2a21c705d..5b0848b6f 100644 --- a/zun/api/controllers/v1/__init__.py +++ b/zun/api/controllers/v1/__init__.py @@ -26,6 +26,7 @@ from zun.api.controllers import base as controllers_base from zun.api.controllers import link from zun.api.controllers import types from zun.api.controllers.v1 import containers as container_controller +from zun.api.controllers.v1 import images as image_controller from zun.api.controllers.v1 import zun_services LOG = logging.getLogger(__name__) @@ -63,6 +64,9 @@ class V1(controllers_base.APIBase): 'containers': { 'validate': types.List(types.Custom(link.Link)).validate }, + 'images': { + 'validate': types.List(types.Custom(link.Link)).validate + }, } @staticmethod @@ -90,6 +94,12 @@ class V1(controllers_base.APIBase): pecan.request.host_url, 'containers', '', bookmark=True)] + v1.images = [link.Link.make_link('self', pecan.request.host_url, + 'images', ''), + link.Link.make_link('bookmark', + pecan.request.host_url, + 'images', '', + bookmark=True)] return v1 @@ -98,6 +108,7 @@ class Controller(rest.RestController): services = zun_services.ZunServiceController() containers = container_controller.ContainersController() + images = image_controller.ImagesController() @pecan.expose('json') def get(self): diff --git a/zun/api/controllers/v1/containers.py b/zun/api/controllers/v1/containers.py index 06ebcb705..4445b9b3a 100644 --- a/zun/api/controllers/v1/containers.py +++ b/zun/api/controllers/v1/containers.py @@ -94,7 +94,7 @@ class Container(base.APIBase): 'validate': types.Float.validate, }, 'memory': { - 'validate': types.ContainerMemory.validate, + 'validate': types.MemoryType.validate, }, 'environment': { 'validate': types.Dict(types.String, types.String).validate, diff --git a/zun/api/controllers/v1/images.py b/zun/api/controllers/v1/images.py new file mode 100644 index 000000000..5525a602b --- /dev/null +++ b/zun/api/controllers/v1/images.py @@ -0,0 +1,188 @@ +# 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. + +from oslo_log import log as logging +from oslo_utils import timeutils +import pecan +from pecan import rest + +from zun.api.controllers import base +from zun.api.controllers import link +from zun.api.controllers import types +from zun.api.controllers.v1 import collection +from zun.api import utils as api_utils +from zun.common import exception +from zun.common.i18n import _LE +from zun.common import policy +from zun import objects + +LOG = logging.getLogger(__name__) + + +class Image(base.APIBase): + """API representation of an image. + + This class enforces type checking and value constraints, and converts + between the internal object model and the API representation of + an image. + """ + + fields = { + 'uuid': { + 'validate': types.Uuid.validate, + }, + 'image_id': { + 'validate': types.NameType.validate, + }, + 'repo': { + 'validate': types.ImageNameType.validate, + }, + 'tag': { + 'validate': types.NameType.validate, + }, + 'size': { + 'validate': types.ImageSize.validate, + }, + } + + def __init__(self, **kwargs): + super(Image, self).__init__(**kwargs) + + @staticmethod + def _convert_with_links(image, url, expand=True): + if not expand: + image.unset_fields_except([ + 'uuid', 'image_id', 'repo', 'tag', 'size']) + + image.links = [link.Link.make_link( + 'self', url, + 'images', image.uuid), + link.Link.make_link( + 'bookmark', url, + 'images', image.uuid, + bookmark=True)] + return image + + @classmethod + def convert_with_links(cls, rpc_image, expand=True): + image = Image(**rpc_image) + return cls._convert_with_links(image, pecan.request.host_url, + expand) + + @classmethod + def sample(cls, expand=True): + sample = cls(uuid='27e3153e-d5bf-4b7e-b517-fb518e17f35c', + repo='ubuntu', + tag='latest', + size='700m', + created_at=timeutils.utcnow(), + updated_at=timeutils.utcnow()) + return cls._convert_with_links(sample, 'http://localhost:9517', expand) + + +class ImageCollection(collection.Collection): + """API representation of a collection of images.""" + + fields = { + 'images': { + 'validate': types.List(types.Custom(Image)).validate, + }, + } + + """A list containing images objects""" + + def __init__(self, **kwargs): + self._type = 'images' + + @staticmethod + def convert_with_links(rpc_images, limit, url=None, + expand=False, **kwargs): + collection = ImageCollection() + collection.images = [Image.convert_with_links(p, expand) + for p in rpc_images] + collection.next = collection.get_next(limit, url=url, **kwargs) + return collection + + @classmethod + def sample(cls): + sample = cls() + sample.images = [Image.sample(expand=False)] + return sample + + +class ImagesController(rest.RestController): + '''Controller for Images''' + + @pecan.expose('json') + @exception.wrap_pecan_controller_exception + def get_all(self, **kwargs): + '''Retrieve a list of images.''' + context = pecan.request.context + policy.enforce(context, "image:get_all", + action="image:get_all") + return self._get_images_collection(**kwargs) + + def _get_images_collection(self, **kwargs): + context = pecan.request.context + limit = api_utils.validate_limit(kwargs.get('limit', None)) + sort_dir = api_utils.validate_sort_dir(kwargs.get('sort_dir', 'asc')) + sort_key = kwargs.get('sort_key', 'id') + resource_url = kwargs.get('resource_url', None) + expand = kwargs.get('expand', None) + filters = None + marker_obj = None + marker = kwargs.get('marker', None) + if marker: + marker_obj = objects.Image.get_by_uuid(context, marker) + images = objects.Image.list(context, + limit, + marker_obj, + sort_key, + sort_dir, + filters=filters) + for i, c in enumerate(images): + try: + images[i] = pecan.request.rpcapi.image_show(context, c) + except Exception as e: + LOG.exception(_LE("Error while list image %(uuid)s: " + "%(e)s."), {'uuid': c.uuid, 'e': e}) + return ImageCollection.convert_with_links(images, limit, + url=resource_url, + expand=expand, + sort_key=sort_key, + sort_dir=sort_dir) + + @pecan.expose('json') + @api_utils.enforce_content_types(['application/json']) + @exception.wrap_pecan_controller_exception + def post(self, **image_dict): + """Create a new image. + + :param image: an image within the request body. + """ + context = pecan.request.context + policy.enforce(context, "image:create", + action="image:create") + image_dict = Image(**image_dict).as_dict() + image_dict['project_id'] = context.project_id + image_dict['user_id'] = context.user_id + repo_tag = image_dict.get('repo') + image_dict['repo'], image_dict['tag'] = api_utils.parse_image_tag( + repo_tag) + new_image = objects.Image(context, **image_dict) + new_image.create() + pecan.request.rpcapi.image_create(context, new_image) + # Set the HTTP Location Header + pecan.response.location = link.build_url('images', + new_image.uuid) + pecan.response.status = 202 + return Image.convert_with_links(new_image) diff --git a/zun/api/utils.py b/zun/api/utils.py index 8934a7f54..c7fcf6881 100644 --- a/zun/api/utils.py +++ b/zun/api/utils.py @@ -68,6 +68,18 @@ def validate_docker_memory(mem_str): % DOCKER_MINIMUM_MEMORY) +def parse_image_tag(image): + image_parts = image.split(':', 1) + + image_repo = image_parts[0] + image_tag = 'latest' + + if len(image_parts) > 1: + image_tag = image_parts[1] + + return image_repo, image_tag + + def apply_jsonpatch(doc, patch): for p in patch: if p['op'] == 'add' and p['path'].count('/') == 1: diff --git a/zun/common/exception.py b/zun/common/exception.py index 43785b722..d1a3a4d0a 100644 --- a/zun/common/exception.py +++ b/zun/common/exception.py @@ -337,10 +337,18 @@ class ContainerNotFound(HTTPNotFound): message = _("Container %(container)s could not be found.") +class ImageNotFound(HTTPNotFound): + message = _("Image %(image)s could not be found.") + + class ContainerAlreadyExists(ResourceExists): message = _("A container with UUID %(uuid)s already exists.") +class ImageAlreadyExists(ResourceExists): + message = _("An image with this tag and repo already exists.") + + class InvalidStateException(ZunException): message = _("Cannot %(action)s container %(id)s in %(actual_state)s state") code = 409 diff --git a/zun/compute/api.py b/zun/compute/api.py index bd42bc133..3c71267c7 100644 --- a/zun/compute/api.py +++ b/zun/compute/api.py @@ -23,6 +23,7 @@ class API(rpc_service.API): API version history: * 1.0 - Initial version. + * 1.1 - Add image endpoints. ''' def __init__(self, transport=None, context=None, topic=None): @@ -67,3 +68,9 @@ class API(rpc_service.API): def container_kill(self, context, container, signal): return self._call('container_kill', container=container, signal=signal) + + def image_show(self, context, image): + return self._call('image_show', image=image) + + def image_create(self, context, image): + return self._cast('image_create', image=image) diff --git a/zun/compute/manager.py b/zun/compute/manager.py index ff586f6c4..748db43e8 100644 --- a/zun/compute/manager.py +++ b/zun/compute/manager.py @@ -267,6 +267,35 @@ class Manager(object): LOG.error(_LE("Error occured while calling docker API: %s"), six.text_type(e)) raise + + def image_create(self, context, image): + utils.spawn_n(self._do_image_create, context, image) + + def _do_image_create(self, context, image): + LOG.debug('Creating image...', context=context, + image=image) + try: + repo_tag = image.repo + ":" + image.tag + self.driver.pull_image(repo_tag) + image_dict = self.driver.inspect_image(repo_tag) + image.image_id = image_dict['Id'] + image.size = image_dict['Size'] + image.save() + except exception.DockerError as e: + LOG.error(_LE("Error occured while calling docker API: %s"), + six.text_type(e)) + raise e + except Exception as e: + LOG.exception(_LE("Unexpected exception: %s"), + six.text_type(e)) + raise e + + @translate_exception + def image_show(self, context, image): + LOG.debug('Listing image...', context=context) + try: + self.image.list() + return image except Exception as e: LOG.exception(_LE("Unexpected exception: %s"), str(e)) raise e diff --git a/zun/container/docker/driver.py b/zun/container/docker/driver.py index 278448c80..3b097ae09 100644 --- a/zun/container/docker/driver.py +++ b/zun/container/docker/driver.py @@ -39,6 +39,17 @@ class DockerDriver(driver.ContainerDriver): image_repo, image_tag = docker_utils.parse_docker_image(image) docker.pull(image_repo, tag=image_tag) + def inspect_image(self, image): + with docker_utils.docker_client() as docker: + LOG.debug('Inspecting image %s' % image) + image_dict = docker.inspect_image(image) + return image_dict + + def images(self, repo, quiet=False): + with docker_utils.docker_client() as docker: + response = docker.images(repo, quiet) + return response + def create(self, container): with docker_utils.docker_client() as docker: name = container.name diff --git a/zun/db/api.py b/zun/db/api.py index 29ff66152..f5e0bdb95 100644 --- a/zun/db/api.py +++ b/zun/db/api.py @@ -197,5 +197,79 @@ class Connection(object): :returns: A list of tuples of the specified columns. """ dbdriver = get_instance() - return dbdriver.get_zun_service_list( - context, disabled, limit, marker, sort_key, sort_dir) + return dbdriver.get_zun_service_list(context, disabled, limit, + marker, sort_key, sort_dir) + + @classmethod + def create_image(cls, values): + """Create a new image. + + :param values: A dict containing several items used to identify + and track the image, and several dicts which are + passed + into the Drivers when managing this image. For + example: + :: + { + 'uuid': uuidutils.generate_uuid(), + 'repo': 'hello-world', + 'tag': 'latest' + } + :returns: An image. + """ + dbdriver = get_instance() + return dbdriver.create_image(values) + + @classmethod + def update_image(self, image_id, values): + """Update properties of an image. + + :param container_id: The id or uuid of an image. + :returns: An Image. + :raises: ImageNotFound + """ + dbdriver = get_instance() + return dbdriver.update_image(image_id, values) + + @classmethod + def list_image(cls, context, filters=None, + limit=None, marker=None, + sort_key=None, sort_dir=None): + """Get matching images. + + Return a list of the specified columns for all images that + match the specified filters. + :param context: The security context + :param filters: Filters to apply. Defaults to None. + :param limit: Maximum number of images to return. + :param marker: the last item of the previous page; we + return the next + :param sort_key: Attribute by which results should be sorted. + (asc, desc) + :returns: A list of tuples of the specified columns. + """ + dbdriver = get_instance() + return dbdriver.list_image(context, filters, limit, marker, + sort_key, sort_dir) + + @classmethod + def get_image_by_id(cls, context, image_id): + """Return an image. + + :param context: The security context + :param image_id: The id of an image. + :returns: An image. + """ + dbdriver = get_instance() + return dbdriver.get_image_by_id(context, image_id) + + @classmethod + def get_image_by_uuid(cls, context, image_uuid): + """Return an image. + + :param context: The security context + :param image_uuid: The uuid of an image. + :returns: An image. + """ + dbdriver = get_instance() + return dbdriver.get_image_by_uuid(context, image_uuid) diff --git a/zun/db/sqlalchemy/alembic/versions/72c6947c6636_create_table_image.py b/zun/db/sqlalchemy/alembic/versions/72c6947c6636_create_table_image.py new file mode 100644 index 000000000..164274e7a --- /dev/null +++ b/zun/db/sqlalchemy/alembic/versions/72c6947c6636_create_table_image.py @@ -0,0 +1,50 @@ +# 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. + +"""create_table_image + +Revision ID: 72c6947c6636 +Revises: 1192ba19a6e9 +Create Date: 2016-09-23 20:30:02.425937 + +""" + +# revision identifiers, used by Alembic. +revision = '72c6947c6636' +down_revision = '1192ba19a6e9' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # commands auto generated by Alembic - please adjust! # + op.create_table( + 'image', + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=255), nullable=True), + sa.Column('user_id', sa.String(length=255), nullable=True), + sa.Column('uuid', sa.String(length=36), nullable=True), + sa.Column('image_id', sa.String(length=255), nullable=True), + sa.Column('repo', sa.String(length=255), nullable=True), + sa.Column('tag', sa.String(length=255), nullable=True), + sa.Column('size', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('repo', 'tag', name='uniq_image0repotag'), + mysql_charset='utf8', + mysql_engine='InnoDB' + ) + # end Alembic commands # diff --git a/zun/db/sqlalchemy/api.py b/zun/db/sqlalchemy/api.py index c08e583bc..500d6bf6a 100644 --- a/zun/db/sqlalchemy/api.py +++ b/zun/db/sqlalchemy/api.py @@ -31,7 +31,6 @@ from zun.db.sqlalchemy import models CONF = cfg.CONF - _FACADE = None @@ -270,3 +269,72 @@ class Connection(api.Connection): return _paginate_query(models.ZunService, limit, marker, sort_key, sort_dir, query) + + def create_image(self, values): + # ensure defaults are present for new containers + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() + image = models.Image() + image.update(values) + try: + image.save() + except db_exc.DBDuplicateEntry: + raise exception.ImageAlreadyExists() + return image + + def update_image(self, image_id, values): + # NOTE(dtantsur): this can lead to very strange errors + if 'uuid' in values: + msg = _("Cannot overwrite UUID for an existing Image.") + raise exception.InvalidParameterValue(err=msg) + return self._do_update_image(image_id, values) + + def _do_update_image(self, image_id, values): + session = get_session() + with session.begin(): + query = model_query(models.Image, session=session) + query = add_identity_filter(query, image_id) + try: + ref = query.with_lockmode('update').one() + except NoResultFound: + raise exception.ImageNotFound(image=image_id) + + ref.update(values) + return ref + + def _add_image_filters(self, query, filters): + if filters is None: + filters = {} + + filter_names = ['repo', 'project_id', 'user_id', 'size'] + for name in filter_names: + if name in filters: + query = query.filter_by(**{name: filters[name]}) + + return query + + def list_image(self, context, filters=None, limit=None, marker=None, + sort_key=None, sort_dir=None): + query = model_query(models.Image) + query = self._add_tenant_filters(context, query) + query = self._add_image_filters(query, filters) + return _paginate_query(models.Image, limit, marker, sort_key, + sort_dir, query) + + def get_image_by_id(self, context, image_id): + query = model_query(models.Image) + query = self._add_tenant_filters(context, query) + query = query.filter_by(id=image_id) + try: + return query.one() + except NoResultFound: + raise exception.ImageNotFound(image=image_id) + + def get_image_by_uuid(self, context, image_uuid): + query = model_query(models.Image) + query = self._add_tenant_filters(context, query) + query = query.filter_by(uuid=image_uuid) + try: + return query.one() + except NoResultFound: + raise exception.ImageNotFound(image=image_uuid) diff --git a/zun/db/sqlalchemy/models.py b/zun/db/sqlalchemy/models.py index ef53b8e84..f0b5481db 100644 --- a/zun/db/sqlalchemy/models.py +++ b/zun/db/sqlalchemy/models.py @@ -139,3 +139,21 @@ class Container(Base): ports = Column(JSONEncodedList) hostname = Column(String(255)) labels = Column(JSONEncodedDict) + + +class Image(Base): + """Represents an image. """ + + __tablename__ = 'image' + __table_args__ = ( + schema.UniqueConstraint('repo', 'tag', name='uniq_image0repotag'), + table_args() + ) + id = Column(Integer, primary_key=True) + project_id = Column(String(255)) + user_id = Column(String(255)) + uuid = Column(String(36)) + image_id = Column(String(255)) + repo = Column(String(255)) + tag = Column(String(255)) + size = Column(String(255)) diff --git a/zun/objects/__init__.py b/zun/objects/__init__.py index 64efe36b2..85222e4b9 100644 --- a/zun/objects/__init__.py +++ b/zun/objects/__init__.py @@ -12,11 +12,13 @@ from zun.objects import container +from zun.objects import image from zun.objects import zun_service - Container = container.Container ZunService = zun_service.ZunService +Image = image.Image __all__ = (Container, - ZunService) + ZunService, + Image) diff --git a/zun/objects/image.py b/zun/objects/image.py new file mode 100644 index 000000000..ba1c390d2 --- /dev/null +++ b/zun/objects/image.py @@ -0,0 +1,129 @@ +# 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. + +from oslo_versionedobjects import fields + +from zun.db import api as dbapi +from zun.objects import base + + +@base.ZunObjectRegistry.register +class Image(base.ZunPersistentObject, base.ZunObject, + base.ZunObjectDictCompat): + # Version 1.0: Initial version + VERSION = '1.0' + + fields = { + 'id': fields.IntegerField(), + 'uuid': fields.StringField(nullable=True), + 'image_id': fields.StringField(nullable=True), + 'project_id': fields.StringField(nullable=True), + 'user_id': fields.StringField(nullable=True), + 'repo': fields.StringField(nullable=True), + 'tag': fields.StringField(nullable=True), + 'size': fields.StringField(nullable=True), + } + + @staticmethod + def _from_db_object(image, db_image): + """Converts a database entity to a formal object.""" + for field in image.fields: + image[field] = db_image[field] + + image.obj_reset_changes() + return image + + @staticmethod + def _from_db_object_list(db_objects, cls, context): + """Converts a list of database entities to a list of formal objects.""" + return [Image._from_db_object(cls(context), obj) + for obj in db_objects] + + @base.remotable_classmethod + def get_by_id(cls, context, image_id): + """Find an image based on its integer id and return a Image object. + + :param image_id: the id of an image. + :param context: Security context + :returns: a :class:`Image` object. + """ + db_image = dbapi.Connection.get_image_by_id(context, image_id) + image = Image._from_db_object(cls(context), db_image) + return image + + @base.remotable_classmethod + def get_by_uuid(cls, context, uuid): + """Find an image based on uuid and return a :class:`Image` object. + + :param uuid: the uuid of an image. + :param context: Security context + :returns: a :class:`Image` object. + """ + db_image = dbapi.Connection.get_image_by_uuid(context, uuid) + image = Image._from_db_object(cls(context), db_image) + return image + + @base.remotable_classmethod + def list(cls, context=None, limit=None, marker=None, + sort_key=None, sort_dir=None, filters=None): + """Return a list of Image objects. + + :param context: Security context. + :param limit: maximum number of resources to return in a single result. + :param marker: pagination marker for large data sets. + :param sort_key: column to sort results by. + :param sort_dir: direction to sort. "asc" or "desc". + :param filters: filters when list images, the filter name could be + 'repo', 'image_id', 'project_id', 'user_id', 'size' + :returns: a list of :class:`Image` object. + + """ + db_images = dbapi.Connection.list_image(context, limit=limit, + marker=marker, + sort_key=sort_key, + sort_dir=sort_dir, + filters=filters) + return Image._from_db_object_list(db_images, cls, context) + + @base.remotable + def create(self, context=None): + """Create an image record in the DB. + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Image(context) + + """ + values = self.obj_get_changes() + db_image = dbapi.Connection.create_image(values) + self._from_db_object(self, db_image) + + @base.remotable + def save(self, context=None): + """Save updates to this Image. + + Updates will be made column by column based on the result + of self.what_changed(). + + :param context: Security context. NOTE: This should only + be used internally by the indirection_api. + Unfortunately, RPC requires context as the first + argument, even though we don't use it. + A context should be set when instantiating the + object, e.g.: Image(context) + """ + updates = self.obj_get_changes() + dbapi.Connection.update_image(self.uuid, updates) + self.obj_reset_changes() diff --git a/zun/tests/unit/api/controllers/test_root.py b/zun/tests/unit/api/controllers/test_root.py index bb1f81b78..5857d80f8 100644 --- a/zun/tests/unit/api/controllers/test_root.py +++ b/zun/tests/unit/api/controllers/test_root.py @@ -50,7 +50,12 @@ class TestRootController(api_base.FunctionalTest): u'containers': [{u'href': u'http://localhost/v1/containers/', u'rel': u'self'}, {u'href': u'http://localhost/containers/', - u'rel': u'bookmark'}]} + u'rel': u'bookmark'}], + u'id': u'v1', + u'images': [{u'href': u'http://localhost/v1/images/', + u'rel': u'self'}, + {u'href': u'http://localhost/images/', + u'rel': u'bookmark'}]} def make_app(self, paste_file): file_name = self.get_path(paste_file) diff --git a/zun/tests/unit/api/controllers/test_types.py b/zun/tests/unit/api/controllers/test_types.py index deda1eb93..0bc054d9c 100644 --- a/zun/tests/unit/api/controllers/test_types.py +++ b/zun/tests/unit/api/controllers/test_types.py @@ -181,27 +181,54 @@ class TestTypes(test_base.BaseTestCase): self.assertRaises(exception.InvalidValue, types.NameType.validate, test_value) + def test_image_name_type(self): + test_value = "" + self.assertRaises(exception.InvalidValue, + types.ImageNameType.validate, test_value) + + test_value = '*' * 256 + self.assertRaises(exception.InvalidValue, + types.ImageNameType.validate, test_value) + def test_container_memory_type(self): test_value = '4m' - value = types.ContainerMemory.validate(test_value) + value = types.MemoryType.validate(test_value) self.assertEqual(value, test_value) test_value = '4' self.assertRaises(exception.InvalidValue, - types.ContainerMemory.validate, test_value) + types.MemoryType.validate, test_value) test_value = '10000A' self.assertRaises(exception.InvalidValue, - types.ContainerMemory.validate, test_value) + types.MemoryType.validate, test_value) test_value = '4K' self.assertRaises(exception.InvalidValue, - types.ContainerMemory.validate, test_value) + types.MemoryType.validate, test_value) test_value = '4194304' - value = types.ContainerMemory.validate(test_value) + value = types.MemoryType.validate(test_value) self.assertEqual(value, test_value) test_value = '4194304.0' self.assertRaises(exception.InvalidValue, - types.ContainerMemory.validate, test_value) + types.MemoryType.validate, test_value) + + def test_image_size(self): + test_value = '400' + value = types.ImageSize.validate(test_value) + self.assertEqual(value, test_value) + + test_value = '4194304.0' + self.assertRaises(exception.InvalidValue, + types.ImageSize.validate, test_value) + + test_value = '10000A' + self.assertRaises(exception.InvalidValue, + types.ImageSize.validate, test_value) + + test_value = '4K' + expected_value = 4096 + value = types.ImageSize.validate(test_value) + self.assertEqual(value, expected_value) diff --git a/zun/tests/unit/api/controllers/v1/test_images.py b/zun/tests/unit/api/controllers/v1/test_images.py new file mode 100644 index 000000000..647e76263 --- /dev/null +++ b/zun/tests/unit/api/controllers/v1/test_images.py @@ -0,0 +1,119 @@ +# 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 mock +from mock import patch + +from zun.common import utils as comm_utils +from zun import objects +from zun.tests.unit.api import base as api_base +from zun.tests.unit.db import utils + + +class TestImageController(api_base.FunctionalTest): + @patch('zun.compute.api.API.image_create') + def test_image_create(self, mock_image_create): + mock_image_create.side_effect = lambda x, y: y + + params = ('{"repo": "hello-world"}') + response = self.app.post('/v1/images/', + params=params, + content_type='application/json') + + self.assertEqual(202, response.status_int) + self.assertTrue(mock_image_create.called) + + @patch('zun.compute.api.API.image_create') + def test_create_image_set_project_id_and_user_id( + self, mock_image_create): + def _create_side_effect(cnxt, image): + self.assertEqual(self.context.project_id, image.project_id) + self.assertEqual(self.context.user_id, image.user_id) + return image + mock_image_create.side_effect = _create_side_effect + + params = ('{"repo": "hello-world"}') + self.app.post('/v1/images/', + params=params, + content_type='application/json') + + @patch('zun.compute.api.API.image_create') + def test_image_create_with_tag(self, mock_image_create): + mock_image_create.side_effect = lambda x, y: y + + params = ('{"repo": "hello-world:latest"}') + response = self.app.post('/v1/images/', + params=params, + content_type='application/json') + + self.assertEqual(202, response.status_int) + self.assertTrue(mock_image_create.called) + + @patch('zun.compute.api.API.image_show') + @patch('zun.objects.Image.list') + def test_get_all_images(self, mock_image_list, mock_image_show): + test_image = utils.get_test_image() + images = [objects.Image(self.context, **test_image)] + mock_image_list.return_value = images + mock_image_show.return_value = images[0] + + response = self.app.get('/v1/images/') + + mock_image_list.assert_called_once_with(mock.ANY, + 1000, None, 'id', 'asc', + filters=None) + self.assertEqual(200, response.status_int) + actual_images = response.json['images'] + self.assertEqual(1, len(actual_images)) + self.assertEqual(test_image['uuid'], + actual_images[0].get('uuid')) + + @patch('zun.compute.api.API.image_show') + @patch('zun.objects.Image.list') + def test_get_all_images_with_pagination_marker(self, mock_image_list, + mock_image_show): + image_list = [] + for id_ in range(4): + test_image = utils.create_test_image( + id=id_, + uuid=comm_utils.generate_uuid()) + image_list.append(objects.Image(self.context, **test_image)) + mock_image_list.return_value = image_list[-1:] + mock_image_show.return_value = image_list[-1] + response = self.app.get('/v1/images/?limit=3&marker=%s' + % image_list[2].uuid) + + self.assertEqual(200, response.status_int) + actual_images = response.json['images'] + self.assertEqual(1, len(actual_images)) + self.assertEqual(image_list[-1].uuid, + actual_images[0].get('uuid')) + + @patch('zun.compute.api.API.image_show') + @patch('zun.objects.Image.list') + def test_get_all_images_with_exception(self, mock_image_list, + mock_image_show): + test_image = utils.get_test_image() + images = [objects.Image(self.context, **test_image)] + mock_image_list.return_value = images + mock_image_show.side_effect = Exception + + response = self.app.get('/v1/images/') + + mock_image_list.assert_called_once_with(mock.ANY, 1000, + None, 'id', 'asc', + filters=None) + self.assertEqual(200, response.status_int) + actual_images = response.json['images'] + self.assertEqual(1, len(actual_images)) + self.assertEqual(test_image['uuid'], + actual_images[0].get('uuid')) diff --git a/zun/tests/unit/db/test_image.py b/zun/tests/unit/db/test_image.py new file mode 100644 index 000000000..2d6cc4d4e --- /dev/null +++ b/zun/tests/unit/db/test_image.py @@ -0,0 +1,132 @@ +# 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. + +"""Tests for manipulating Images via the DB API""" +from oslo_utils import uuidutils +import six + +from zun.common import exception +from zun.tests.unit.db import base +from zun.tests.unit.db import utils + + +class DbImageTestCase(base.DbTestCase): + + def test_create_image(self): + utils.create_test_image(repo="ubuntu:latest") + + def test_create_image_duplicate_repo(self): + utils.create_test_image(repo="ubuntu:latest") + utils.create_test_image(repo="ubuntu:14.04") + + def test_create_image_duplicate_tag(self): + utils.create_test_image(repo="ubuntu:latest") + utils.create_test_image(repo="centos:latest") + + def test_create_image_already_exists(self): + utils.create_test_image(repo="ubuntu:latest") + self.assertRaises(exception.ResourceExists, + utils.create_test_image, + repo="ubuntu:latest") + + def test_get_image_by_id(self): + image = utils.create_test_image() + res = self.dbapi.get_image_by_id(self.context, image.id) + self.assertEqual(image.id, res.id) + self.assertEqual(image.uuid, res.uuid) + + def test_get_image_by_uuid(self): + image = utils.create_test_image() + res = self.dbapi.get_image_by_uuid(self.context, image.uuid) + self.assertEqual(image.id, res.id) + self.assertEqual(image.uuid, res.uuid) + + def test_get_image_that_does_not_exist(self): + self.assertRaises(exception.ImageNotFound, + self.dbapi.get_image_by_id, self.context, 99) + self.assertRaises(exception.ImageNotFound, + self.dbapi.get_image_by_uuid, + self.context, + uuidutils.generate_uuid()) + + def test_list_images(self): + uuids = [] + for i in range(1, 6): + image = utils.create_test_image( + repo="testrepo" + str(i)) + uuids.append(six.text_type(image['uuid'])) + res = self.dbapi.list_image(self.context) + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), sorted(res_uuids)) + + def test_list_image_sorted(self): + uuids = [] + for _ in range(5): + image = utils.create_test_image( + uuid=uuidutils.generate_uuid()) + uuids.append(six.text_type(image.uuid)) + res = self.dbapi.list_image(self.context, sort_key='uuid') + res_uuids = [r.uuid for r in res] + self.assertEqual(sorted(uuids), res_uuids) + + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.list_image, + self.context, + sort_key='foo') + + def test_list_image_with_filters(self): + image1 = utils.create_test_image( + repo='image-one', + uuid=uuidutils.generate_uuid()) + image2 = utils.create_test_image( + repo='image-two', + uuid=uuidutils.generate_uuid()) + + res = self.dbapi.list_image(self.context, + filters={'repo': 'image-one'}) + self.assertEqual([image1.id], [r.id for r in res]) + + res = self.dbapi.list_image(self.context, + filters={'repo': 'image-two'}) + self.assertEqual([image2.id], [r.id for r in res]) + + res = self.dbapi.list_image(self.context, + filters={'repo': 'bad-image'}) + self.assertEqual([], [r.id for r in res]) + + res = self.dbapi.list_image( + self.context, + filters={'repo': image1.repo}) + self.assertEqual([image1.id], [r.id for r in res]) + + def test_update_image(self): + image = utils.create_test_image() + old_size = image.size + new_size = '2000' + self.assertNotEqual(old_size, new_size) + + res = self.dbapi.update_image(image.id, + {'size': new_size}) + self.assertEqual(new_size, res.size) + + def test_update_image_not_found(self): + image_uuid = uuidutils.generate_uuid() + new_size = '2000' + self.assertRaises(exception.ImageNotFound, + self.dbapi.update_image, + image_uuid, {'size': new_size}) + + def test_update_image_uuid(self): + image = utils.create_test_image() + self.assertRaises(exception.InvalidParameterValue, + self.dbapi.update_image, image.id, + {'uuid': ''}) diff --git a/zun/tests/unit/db/utils.py b/zun/tests/unit/db/utils.py index 5e64f8aa3..4c7c98268 100644 --- a/zun/tests/unit/db/utils.py +++ b/zun/tests/unit/db/utils.py @@ -12,6 +12,7 @@ """Zun test utilities.""" +from zun.common import name_generator from zun.db import api as db_api @@ -52,3 +53,42 @@ def create_test_container(**kw): del container['id'] dbapi = db_api.get_instance() return dbapi.create_container(container) + + +def get_test_image(**kw): + return { + 'id': kw.get('id', 42), + 'uuid': kw.get('uuid', 'ea8e2a25-2901-438d-8157-de7ffd68d051'), + 'repo': kw.get('repo', 'image1'), + 'tag': kw.get('tag', 'latest'), + 'image_id': kw.get('image_id', 'sha256:c54a2cc56cbb2f0400'), + 'size': kw.get('size', '1848'), + 'project_id': kw.get('project_id', 'fake_project'), + 'user_id': kw.get('user_id', 'fake_user'), + 'created_at': kw.get('created_at'), + 'updated_at': kw.get('updated_at'), + } + + +def create_test_image(**kw): + """Create test image entry in DB and return Image DB object. + + Function to be used to create test Image objects in the database. + :param kw: kwargs with overriding values for image's attributes. + :returns: Test Image DB object. + """ + image = get_test_image(**kw) + # Let DB generate ID if it isn't specified explicitly + if 'id' not in kw: + del image['id'] + if 'repo' not in kw: + image['repo'] = _generate_repo_for_image() + dbapi = db_api.get_instance() + return dbapi.create_image(image) + + +def _generate_repo_for_image(): + '''Generate a random name like: zeta-22-image.''' + name_gen = name_generator.NameGenerator() + name = name_gen.generate() + return name + '-image' diff --git a/zun/tests/unit/objects/test_image.py b/zun/tests/unit/objects/test_image.py new file mode 100644 index 000000000..1acf1a13c --- /dev/null +++ b/zun/tests/unit/objects/test_image.py @@ -0,0 +1,101 @@ +# Copyright 2015 OpenStack Foundation +# 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 mock +from testtools.matchers import HasLength + +from zun import objects +from zun.tests.unit.db import base +from zun.tests.unit.db import utils + + +class TestImageObject(base.DbTestCase): + + def setUp(self): + super(TestImageObject, self).setUp() + self.fake_image = utils.get_test_image() + + def test_get_by_id(self): + image_id = self.fake_image['id'] + with mock.patch.object(self.dbapi, 'get_image_by_id', + autospec=True) as mock_get_image: + mock_get_image.return_value = self.fake_image + image = objects.Image.get_by_id(self.context, + image_id) + mock_get_image.assert_called_once_with(self.context, + image_id) + self.assertEqual(self.context, image._context) + + def test_get_by_uuid(self): + uuid = self.fake_image['uuid'] + with mock.patch.object(self.dbapi, 'get_image_by_uuid', + autospec=True) as mock_get_image: + mock_get_image.return_value = self.fake_image + image = objects.Image.get_by_uuid(self.context, uuid) + mock_get_image.assert_called_once_with(self.context, uuid) + self.assertEqual(self.context, image._context) + + def test_list(self): + with mock.patch.object(self.dbapi, 'list_image', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_image] + images = objects.Image.list(self.context) + self.assertEqual(1, mock_get_list.call_count) + self.assertThat(images, HasLength(1)) + self.assertIsInstance(images[0], objects.Image) + self.assertEqual(self.context, images[0]._context) + + def test_list_with_filters(self): + with mock.patch.object(self.dbapi, 'list_image', + autospec=True) as mock_get_list: + mock_get_list.return_value = [self.fake_image] + filt = {'id': '1'} + images = objects.Image.list(self.context, filters=filt) + self.assertEqual(1, mock_get_list.call_count) + self.assertThat(images, HasLength(1)) + self.assertIsInstance(images[0], objects.Image) + self.assertEqual(self.context, images[0]._context) + mock_get_list.assert_called_once_with(self.context, + filters=filt, + limit=None, marker=None, + sort_key=None, sort_dir=None) + + def test_create(self): + with mock.patch.object(self.dbapi, 'create_image', + autospec=True) as mock_create_image: + mock_create_image.return_value = self.fake_image + image = objects.Image(self.context, **self.fake_image) + image.create() + mock_create_image.assert_called_once_with(self.fake_image) + self.assertEqual(self.context, image._context) + + def test_save(self): + uuid = self.fake_image['uuid'] + with mock.patch.object(self.dbapi, 'get_image_by_uuid', + autospec=True) as mock_get_image: + mock_get_image.return_value = self.fake_image + with mock.patch.object(self.dbapi, 'update_image', + autospec=True) as mock_update_image: + image = objects.Image.get_by_uuid(self.context, uuid) + image.repo = 'image-test' + image.tag = '512' + image.save() + + mock_get_image.assert_called_once_with(self.context, uuid) + mock_update_image.assert_called_once_with(uuid, + {'repo': + 'image-test', + 'tag': '512'}) + self.assertEqual(self.context, image._context)