import logging import hashlib import json from collections import defaultdict from datetime import datetime import dateutil.parser from peewee import JOIN, IntegrityError, fn from data.model import (DataModelException, db_transaction, _basequery, storage, InvalidImageException) from data.database import (Image, Repository, ImageStoragePlacement, Namespace, ImageStorage, ImageStorageLocation, RepositoryPermission, DerivedStorageForImage, ImageStorageTransformation, User) from util.canonicaljson import canonicalize logger = logging.getLogger(__name__) def _namespace_id_for_username(username): try: return User.get(username=username).id except User.DoesNotExist: return None def get_image_with_storage(docker_image_id, storage_uuid): """ Returns the image with the given docker image ID and storage uuid or None if none. """ try: return (Image .select(Image, ImageStorage) .join(ImageStorage) .where(Image.docker_image_id == docker_image_id, ImageStorage.uuid == storage_uuid) .get()) except Image.DoesNotExist: return None def get_parent_images(namespace_name, repository_name, image_obj): """ Returns a list of parent Image objects starting with the most recent parent and ending with the base layer. The images in this query will include the storage. """ parents = image_obj.ancestors # Ancestors are in the format ///...//, with each path section # containing the database Id of the image row. parent_db_ids = parents.strip('/').split('/') if parent_db_ids == ['']: return [] def filter_to_parents(query): return query.where(Image.id << parent_db_ids) parents = _get_repository_images_and_storages(namespace_name, repository_name, filter_to_parents) id_to_image = {unicode(image.id): image for image in parents} try: return [id_to_image[parent_id] for parent_id in reversed(parent_db_ids)] except KeyError as ke: logger.exception('Could not find an expected parent image for image %s', image_obj.id) raise DataModelException('Unknown parent image') def get_placements_for_images(images): """ Returns the placements for the given images, as a map from image storage ID to placements. """ if not images: return {} query = (ImageStoragePlacement .select(ImageStoragePlacement, ImageStorageLocation, ImageStorage) .join(ImageStorageLocation) .switch(ImageStoragePlacement) .join(ImageStorage) .where(ImageStorage.id << [image.storage_id for image in images])) placement_map = defaultdict(list) for placement in query: placement_map[placement.storage.id].append(placement) return dict(placement_map) def get_image_and_placements(namespace_name, repo_name, docker_image_id): """ Returns the repo image (with a storage object) and storage placements for the image or (None, None) if non found. """ repo_image = get_repo_image_and_storage(namespace_name, repo_name, docker_image_id) if repo_image is None: return (None, None) query = (ImageStoragePlacement .select(ImageStoragePlacement, ImageStorageLocation) .join(ImageStorageLocation) .switch(ImageStoragePlacement) .join(ImageStorage) .where(ImageStorage.id == repo_image.storage_id)) return repo_image, list(query) def get_repo_image(namespace_name, repository_name, docker_image_id): """ Returns the repository image with the given Docker image ID or None if none. Does not include the storage object. """ def limit_to_image_id(query): return query.where(Image.docker_image_id == docker_image_id).limit(1) query = _get_repository_images(namespace_name, repository_name, limit_to_image_id) try: return query.get() except Image.DoesNotExist: return None def get_repo_image_and_storage(namespace_name, repository_name, docker_image_id): """ Returns the repository image with the given Docker image ID or None if none. Includes the storage object. """ def limit_to_image_id(query): return query.where(Image.docker_image_id == docker_image_id) images = _get_repository_images_and_storages(namespace_name, repository_name, limit_to_image_id) if not images: return None return images[0] def get_image_by_id(namespace_name, repository_name, docker_image_id): """ Returns the repository image with the given Docker image ID or raises if not found. Includes the storage object. """ image = get_repo_image_and_storage(namespace_name, repository_name, docker_image_id) if not image: raise InvalidImageException('Unable to find image \'%s\' for repo \'%s/%s\'' % (docker_image_id, namespace_name, repository_name)) return image def _get_repository_images_and_storages(namespace_name, repository_name, query_modifier): query = (Image .select(Image, ImageStorage) .join(ImageStorage) .switch(Image) .join(Repository) .join(Namespace, on=(Repository.namespace_user == Namespace.id)) .where(Repository.name == repository_name, Namespace.username == namespace_name)) query = query_modifier(query) return query def _get_repository_images(namespace_name, repository_name, query_modifier): query = (Image .select() .join(Repository) .join(Namespace, on=(Repository.namespace_user == Namespace.id)) .where(Repository.name == repository_name, Namespace.username == namespace_name)) query = query_modifier(query) return query def lookup_repository_images(repo, docker_image_ids): return (Image .select(Image, ImageStorage) .join(ImageStorage) .where(Image.repository == repo, Image.docker_image_id << docker_image_ids)) def get_repository_images_without_placements(repo_obj, with_ancestor=None): query = (Image .select(Image, ImageStorage) .join(ImageStorage) .where(Image.repository == repo_obj)) if with_ancestor: ancestors_string = '%s%s/' % (with_ancestor.ancestors, with_ancestor.id) query = query.where((Image.ancestors ** (ancestors_string + '%')) | (Image.id == with_ancestor.id)) return query def get_repository_images(namespace_name, repository_name): """ Returns all the repository images in the repository. Does not include storage objects. """ return _get_repository_images(namespace_name, repository_name, lambda q: q) def __translate_ancestry(old_ancestry, translations, repo_obj, username, preferred_location): if old_ancestry == '/': return '/' def translate_id(old_id, docker_image_id): logger.debug('Translating id: %s', old_id) if old_id not in translations: image_in_repo = find_create_or_link_image(docker_image_id, repo_obj, username, translations, preferred_location) translations[old_id] = image_in_repo.id return translations[old_id] # Select all the ancestor Docker IDs in a single query. old_ids = [int(id_str) for id_str in old_ancestry.split('/')[1:-1]] query = Image.select(Image.id, Image.docker_image_id).where(Image.id << old_ids) old_images = {i.id: i.docker_image_id for i in query} # Translate the old images into new ones. new_ids = [str(translate_id(old_id, old_images[old_id])) for old_id in old_ids] return '/%s/' % '/'.join(new_ids) def _find_or_link_image(existing_image, repo_obj, username, translations, preferred_location): with db_transaction(): # Check for an existing image, under the transaction, to make sure it doesn't already exist. repo_image = get_repo_image(repo_obj.namespace_user.username, repo_obj.name, existing_image.docker_image_id) if repo_image: return repo_image # Make sure the existing base image still exists. try: to_copy = Image.select().join(ImageStorage).where(Image.id == existing_image.id).get() msg = 'Linking image to existing storage with docker id: %s and uuid: %s' logger.debug(msg, existing_image.docker_image_id, to_copy.storage.uuid) new_image_ancestry = __translate_ancestry(to_copy.ancestors, translations, repo_obj, username, preferred_location) copied_storage = to_copy.storage translated_parent_id = None if new_image_ancestry != '/': translated_parent_id = int(new_image_ancestry.split('/')[-2]) new_image = Image.create(docker_image_id=existing_image.docker_image_id, repository=repo_obj, storage=copied_storage, ancestors=new_image_ancestry, command=existing_image.command, created=existing_image.created, comment=existing_image.comment, v1_json_metadata=existing_image.v1_json_metadata, aggregate_size=existing_image.aggregate_size, parent=translated_parent_id, v1_checksum=existing_image.v1_checksum) logger.debug('Storing translation %s -> %s', existing_image.id, new_image.id) translations[existing_image.id] = new_image.id return new_image except Image.DoesNotExist: return None def find_create_or_link_image(docker_image_id, repo_obj, username, translations, preferred_location): # First check for the image existing in the repository. If found, we simply return it. repo_image = get_repo_image(repo_obj.namespace_user.username, repo_obj.name, docker_image_id) if repo_image: return repo_image # We next check to see if there is an existing storage the new image can link to. existing_image_query = (Image .select(Image, ImageStorage) .distinct() .join(ImageStorage) .switch(Image) .join(Repository) .join(RepositoryPermission, JOIN.LEFT_OUTER) .switch(Repository) .join(Namespace, on=(Repository.namespace_user == Namespace.id)) .where(ImageStorage.uploading == False, Image.docker_image_id == docker_image_id)) existing_image_query = _basequery.filter_to_repos_for_user(existing_image_query, _namespace_id_for_username(username)) # If there is an existing image, we try to translate its ancestry and copy its storage. new_image = None try: logger.debug('Looking up existing image for ID: %s', docker_image_id) existing_image = existing_image_query.get() logger.debug('Existing image %s found for ID: %s', existing_image.id, docker_image_id) new_image = _find_or_link_image(existing_image, repo_obj, username, translations, preferred_location) if new_image: return new_image except Image.DoesNotExist: logger.debug('No existing image found for ID: %s', docker_image_id) # Otherwise, create a new storage directly. with db_transaction(): # Final check for an existing image, under the transaction. repo_image = get_repo_image(repo_obj.namespace_user.username, repo_obj.name, docker_image_id) if repo_image: return repo_image logger.debug('Creating new storage for docker id: %s', docker_image_id) new_storage = storage.create_v1_storage(preferred_location) return Image.create(docker_image_id=docker_image_id, repository=repo_obj, storage=new_storage, ancestors='/') def set_image_metadata(docker_image_id, namespace_name, repository_name, created_date_str, comment, command, v1_json_metadata, parent=None): """ Sets metadata that is specific to how a binary piece of storage fits into the layer tree. """ with db_transaction(): try: fetched = (Image .select(Image, ImageStorage) .join(Repository) .join(Namespace, on=(Repository.namespace_user == Namespace.id)) .switch(Image) .join(ImageStorage) .where(Repository.name == repository_name, Namespace.username == namespace_name, Image.docker_image_id == docker_image_id) .get()) except Image.DoesNotExist: raise DataModelException('No image with specified id and repository') fetched.created = datetime.now() if created_date_str is not None: try: fetched.created = dateutil.parser.parse(created_date_str).replace(tzinfo=None) except: # parse raises different exceptions, so we cannot use a specific kind of handler here. pass # We cleanup any old checksum in case it's a retry after a fail fetched.v1_checksum = None fetched.comment = comment fetched.command = command fetched.v1_json_metadata = v1_json_metadata if parent: fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id) fetched.parent = parent fetched.save() return fetched def get_image(repo, docker_image_id): try: return (Image .select(Image, ImageStorage) .join(ImageStorage) .where(Image.docker_image_id == docker_image_id, Image.repository == repo) .get()) except Image.DoesNotExist: return None def get_image_by_db_id(id): try: return Image.get(id=id) except Image.DoesNotExist: return None def synthesize_v1_image(repo, image_storage_id, storage_image_size, docker_image_id, created_date_str, comment, command, v1_json_metadata, parent_image=None): """ Find an existing image with this docker image id, and if none exists, write one with the specified metadata. """ ancestors = '/' if parent_image is not None: ancestors = '{0}{1}/'.format(parent_image.ancestors, parent_image.id) created = None if created_date_str is not None: try: created = dateutil.parser.parse(created_date_str).replace(tzinfo=None) except: # parse raises different exceptions, so we cannot use a specific kind of handler here. pass # Get the aggregate size for the image. aggregate_size = _basequery.calculate_image_aggregate_size(ancestors, storage_image_size, parent_image) try: return Image.create(docker_image_id=docker_image_id, ancestors=ancestors, comment=comment, command=command, v1_json_metadata=v1_json_metadata, created=created, storage=image_storage_id, repository=repo, parent=parent_image, aggregate_size=aggregate_size) except IntegrityError: return Image.get(docker_image_id=docker_image_id, repository=repo) def ensure_image_locations(*names): with db_transaction(): locations = ImageStorageLocation.select().where(ImageStorageLocation.name << names) insert_names = list(names) for location in locations: insert_names.remove(location.name) if not insert_names: return data = [{'name': name} for name in insert_names] ImageStorageLocation.insert_many(data).execute() def get_max_id_for_sec_scan(): """ Gets the maximum id for a clair sec scan """ return Image.select(fn.Max(Image.id)).scalar() def get_min_id_for_sec_scan(version): """ Gets the minimum id for a clair sec scan """ return (Image .select(fn.Min(Image.id)) .where(Image.security_indexed_engine < version) .scalar()) def total_image_count(): """ Returns the total number of images in DB """ return Image.select().count() def get_image_pk_field(): """ Returns the primary key for Image DB model """ return Image.id def get_images_eligible_for_scan(clair_version): """ Returns a query that gives all images eligible for a clair scan """ return (get_image_with_storage_and_parent_base() .where(Image.security_indexed_engine < clair_version) .where(ImageStorage.uploading == False)) def get_image_with_storage_and_parent_base(): Parent = Image.alias() ParentImageStorage = ImageStorage.alias() return (Image .select(Image, ImageStorage, Parent, ParentImageStorage) .join(ImageStorage) .switch(Image) .join(Parent, JOIN.LEFT_OUTER, on=(Image.parent == Parent.id)) .join(ParentImageStorage, JOIN.LEFT_OUTER, on=(ParentImageStorage.id == Parent.storage))) def set_secscan_status(image, indexed, version): return (Image .update(security_indexed=indexed, security_indexed_engine=version) .where(Image.id == image.id) .where((Image.security_indexed_engine != version) | (Image.security_indexed != indexed)) .execute()) != 0 def _get_uniqueness_hash(varying_metadata): if not varying_metadata: return None return hashlib.sha256(json.dumps(canonicalize(varying_metadata))).hexdigest() def find_or_create_derived_storage(source_image, transformation_name, preferred_location, varying_metadata=None): existing = find_derived_storage_for_image(source_image, transformation_name, varying_metadata) if existing is not None: return existing uniqueness_hash = _get_uniqueness_hash(varying_metadata) trans = ImageStorageTransformation.get(name=transformation_name) new_storage = storage.create_v1_storage(preferred_location) try: derived = DerivedStorageForImage.create(source_image=source_image, derivative=new_storage, transformation=trans, uniqueness_hash=uniqueness_hash) except IntegrityError: # Storage was created while this method executed. Just return the existing. ImageStoragePlacement.delete().where(ImageStoragePlacement.storage == new_storage).execute() new_storage.delete_instance() return find_derived_storage_for_image(source_image, transformation_name, varying_metadata) return derived def find_derived_storage_for_image(source_image, transformation_name, varying_metadata=None): uniqueness_hash = _get_uniqueness_hash(varying_metadata) try: found = (DerivedStorageForImage .select(ImageStorage, DerivedStorageForImage) .join(ImageStorage) .switch(DerivedStorageForImage) .join(ImageStorageTransformation) .where(DerivedStorageForImage.source_image == source_image, ImageStorageTransformation.name == transformation_name, DerivedStorageForImage.uniqueness_hash == uniqueness_hash) .get()) return found except DerivedStorageForImage.DoesNotExist: return None def delete_derived_storage(derived_storage): derived_storage.derivative.delete_instance(recursive=True)