From 724b1607d7058a7ed7cda96b9de1d7887ec08516 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Sun, 28 Jun 2015 13:29:22 +0300 Subject: [PATCH] Add automatic storage replication Adds a worker to automatically replicate data between storages and update the database accordingly --- app.py | 1 + conf/init/service/storagereplication/log/run | 2 + conf/init/service/storagereplication/run | 8 ++ config.py | 5 + data/database.py | 11 ++- .../9512773a4a2_add_userregion_table.py | 35 +++++++ data/model/storage.py | 6 ++ data/model/user.py | 15 ++- endpoints/v1/registry.py | 55 ++++++----- initdb.py | 12 +++ storage/__init__.py | 3 +- storage/basestorage.py | 6 +- storage/cloud.py | 23 ++++- storage/distributedstorage.py | 16 +++- storage/fakestorage.py | 4 + storage/local.py | 6 +- test/data/test.db | Bin 802816 -> 815104 bytes workers/storagereplication.py | 86 ++++++++++++++++++ 18 files changed, 259 insertions(+), 35 deletions(-) create mode 100755 conf/init/service/storagereplication/log/run create mode 100755 conf/init/service/storagereplication/run create mode 100644 data/migrations/versions/9512773a4a2_add_userregion_table.py create mode 100644 workers/storagereplication.py diff --git a/app.py b/app.py index d87c4e89a..b42b4f5f7 100644 --- a/app.py +++ b/app.py @@ -136,6 +136,7 @@ google_login = GoogleOAuthConfig(app.config, 'GOOGLE_LOGIN_CONFIG') oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login] image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf) +image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf) dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf, reporter=MetricQueueReporter(metric_queue)) notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf) diff --git a/conf/init/service/storagereplication/log/run b/conf/init/service/storagereplication/log/run new file mode 100755 index 000000000..adcd2b63f --- /dev/null +++ b/conf/init/service/storagereplication/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t storagereplication \ No newline at end of file diff --git a/conf/init/service/storagereplication/run b/conf/init/service/storagereplication/run new file mode 100755 index 000000000..ed62731f8 --- /dev/null +++ b/conf/init/service/storagereplication/run @@ -0,0 +1,8 @@ +#! /bin/bash + +echo 'Starting storage replication worker' + +cd / +venv/bin/python -m workers.storagereplication 2>&1 + +echo 'Repository storage replication exited' \ No newline at end of file diff --git a/config.py b/config.py index 78d614b80..a1ecb2d52 100644 --- a/config.py +++ b/config.py @@ -130,6 +130,7 @@ class DefaultConfig(object): NOTIFICATION_QUEUE_NAME = 'notification' DIFFS_QUEUE_NAME = 'imagediff' DOCKERFILE_BUILD_QUEUE_NAME = 'dockerfilebuild' + REPLICATION_QUEUE_NAME = 'imagestoragereplication' # Super user config. Note: This MUST BE an empty list for the default config. SUPER_USERS = [] @@ -180,6 +181,9 @@ class DefaultConfig(object): # basic auth. FEATURE_REQUIRE_ENCRYPTED_BASIC_AUTH = False + # Feature Flag: Whether to automatically replicate between storage engines. + FEATURE_STORAGE_REPLICATION = False + BUILD_MANAGER = ('enterprise', {}) DISTRIBUTED_STORAGE_CONFIG = { @@ -188,6 +192,7 @@ class DefaultConfig(object): } DISTRIBUTED_STORAGE_PREFERENCE = ['local_us'] + DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS = ['local_us'] # Health checker. HEALTH_CHECKER = ('LocalHealthCheck', {}) diff --git a/data/database.py b/data/database.py index 6309c4eb4..cc802ac59 100644 --- a/data/database.py +++ b/data/database.py @@ -539,6 +539,15 @@ class ImageStoragePlacement(BaseModel): ) +class UserRegion(BaseModel): + user = QuayUserField(index=True, allows_robots=False) + location = ForeignKeyField(ImageStorageLocation) + + indexes = ( + (('user', 'location'), True), + ) + + class Image(BaseModel): # This class is intentionally denormalized. Even though images are supposed # to be globally unique we can't treat them as such for permissions and @@ -751,4 +760,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification, RepositoryAuthorizedEmail, ImageStorageTransformation, DerivedImageStorage, TeamMemberInvite, ImageStorageSignature, ImageStorageSignatureKind, - AccessTokenKind, Star, RepositoryActionCount, TagManifest] + AccessTokenKind, Star, RepositoryActionCount, TagManifest, UserRegion] diff --git a/data/migrations/versions/9512773a4a2_add_userregion_table.py b/data/migrations/versions/9512773a4a2_add_userregion_table.py new file mode 100644 index 000000000..212110054 --- /dev/null +++ b/data/migrations/versions/9512773a4a2_add_userregion_table.py @@ -0,0 +1,35 @@ +"""Add UserRegion table + +Revision ID: 9512773a4a2 +Revises: 499f6f08de3 +Create Date: 2015-09-01 14:17:08.628052 + +""" + +# revision identifiers, used by Alembic. +revision = '9512773a4a2' +down_revision = '499f6f08de3' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('userregion', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['location_id'], ['imagestoragelocation.id'], name=op.f('fk_userregion_location_id_imagestoragelocation')), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_userregion_user_id_user')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_userregion')) + ) + op.create_index('userregion_location_id', 'userregion', ['location_id'], unique=False) + op.create_index('userregion_user_id', 'userregion', ['user_id'], unique=False) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('userregion') + ### end Alembic commands ### diff --git a/data/model/storage.py b/data/model/storage.py index d1ab07b85..97b94ed4e 100644 --- a/data/model/storage.py +++ b/data/model/storage.py @@ -11,6 +11,12 @@ from data.database import (ImageStorage, Image, DerivedImageStorage, ImageStorag logger = logging.getLogger(__name__) +def add_storage_placement(storage, location_name): + """ Adds a storage placement for the given storage at the given location. """ + location = ImageStorageLocation.get(name=location_name) + ImageStoragePlacement.create(location=location, storage=storage) + + def find_or_create_derived_storage(source, transformation_name, preferred_location): existing = find_derived_storage(source, transformation_name) if existing is not None: diff --git a/data/model/user.py b/data/model/user.py index e5b34a099..1a7709ec7 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -8,7 +8,8 @@ from datetime import datetime, timedelta from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember, Team, Repository, TupleSelector, TeamRole, Namespace, Visibility, - EmailConfirmation, Role, db_for_update, random_string_generator) + EmailConfirmation, Role, db_for_update, random_string_generator, + UserRegion, ImageStorageLocation) from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException, InvalidUsernameException, InvalidEmailAddressException, TooManyUsersException, TooManyLoginAttemptsException, db_transaction, @@ -463,6 +464,13 @@ def get_user_by_id(user_db_id): return None +def get_namespace_user_by_user_id(namespace_user_db_id): + try: + return User.get(User.id == namespace_user_db_id, User.robot == False) + except User.DoesNotExist: + raise InvalidUsernameException('User with id does not exist: %s' % namespace_user_db_id) + + def get_namespace_by_user_id(namespace_user_db_id): try: return User.get(User.id == namespace_user_db_id, User.robot == False).username @@ -664,3 +672,8 @@ def get_pull_credentials(robotname): 'registry': '%s://%s/v1/' % (config.app_config['PREFERRED_URL_SCHEME'], config.app_config['SERVER_HOSTNAME']), } + +def get_region_locations(user): + """ Returns the locations defined as preferred storage for the given user. """ + query = UserRegion.select().join(ImageStorageLocation).where(UserRegion.user == user) + return set([region.location.name for region in query]) diff --git a/endpoints/v1/registry.py b/endpoints/v1/registry.py index 2241a6089..7267fa0df 100644 --- a/endpoints/v1/registry.py +++ b/endpoints/v1/registry.py @@ -1,12 +1,13 @@ import logging import json +import features from flask import make_response, request, session, Response, redirect, abort as flask_abort from functools import wraps from datetime import datetime from time import time -from app import storage as store, image_diff_queue, app +from app import storage as store, image_diff_queue, image_replication_queue, app from auth.auth import process_auth, extract_namespace_repo_from_session from auth.auth_context import get_authenticated_user, get_grant_user_context from digest import checksums @@ -55,6 +56,30 @@ def set_uploading_flag(repo_image, is_image_uploading): repo_image.storage.save() +def _finish_image(namespace, repository, repo_image): + # Checksum is ok, we remove the marker + set_uploading_flag(repo_image, False) + + image_id = repo_image.docker_image_id + + # The layer is ready for download, send a job to the work queue to + # process it. + logger.debug('Adding layer to diff queue') + repo = model.repository.get_repository(namespace, repository) + image_diff_queue.put([repo.namespace_user.username, repository, image_id], json.dumps({ + 'namespace_user_id': repo.namespace_user.id, + 'repository': repository, + 'image_id': image_id, + })) + + # Send a job to the work queue to replicate the image layer. + if features.STORAGE_REPLICATION: + image_replication_queue.put([repo_image.storage.uuid], json.dumps({ + 'namespace_user_id': repo.namespace_user.id, + 'storage_id': repo_image.storage.uuid, + })) + + def require_completion(f): """This make sure that the image push correctly finished.""" @wraps(f) @@ -260,18 +285,8 @@ def put_image_layer(namespace, repository, image_id): abort(400, 'Checksum mismatch; ignoring the layer for image %(image_id)s', issue='checksum-mismatch', image_id=image_id) - # Checksum is ok, we remove the marker - set_uploading_flag(repo_image, False) - - # The layer is ready for download, send a job to the work queue to - # process it. - logger.debug('Adding layer to diff queue') - repo = model.repository.get_repository(namespace, repository) - image_diff_queue.put([repo.namespace_user.username, repository, image_id], json.dumps({ - 'namespace_user_id': repo.namespace_user.id, - 'repository': repository, - 'image_id': image_id, - })) + # Mark the image as uploaded. + _finish_image(namespace, repository, repo_image) return make_response('true', 200) @@ -335,18 +350,8 @@ def put_image_checksum(namespace, repository, image_id): abort(400, 'Checksum mismatch for image: %(image_id)s', issue='checksum-mismatch', image_id=image_id) - # Checksum is ok, we remove the marker - set_uploading_flag(repo_image, False) - - # The layer is ready for download, send a job to the work queue to - # process it. - logger.debug('Adding layer to diff queue') - repo = model.repository.get_repository(namespace, repository) - image_diff_queue.put([repo.namespace_user.username, repository, image_id], json.dumps({ - 'namespace_user_id': repo.namespace_user.id, - 'repository': repository, - 'image_id': image_id, - })) + # Mark the image as uploaded. + _finish_image(namespace, repository, repo_image) return make_response('true', 200) diff --git a/initdb.py b/initdb.py index 2de817bca..be50d12c4 100644 --- a/initdb.py +++ b/initdb.py @@ -19,6 +19,7 @@ from data.database import (db, all_models, Role, TeamRole, Visibility, LoginServ ExternalNotificationEvent, ExternalNotificationMethod, NotificationKind) from data import model from app import app, storage as store +from storage.basestorage import StoragePaths from workers import repositoryactioncounter @@ -84,6 +85,17 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): new_image.storage.checksum = checksum new_image.storage.save() + # Write some data for the storage. + if os.environ.get('WRITE_STORAGE_FILES'): + storage_paths = StoragePaths() + paths = [storage_paths.image_json_path, + storage_paths.image_ancestry_path, + storage_paths.image_layer_path] + + for path_builder in paths: + path = path_builder(new_image.storage.uuid) + store.put_content('local_us', path, checksum) + creation_time = REFERENCE_DATE + timedelta(weeks=image_num) + timedelta(days=model_num) command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)] command = json.dumps(command_list) if command_list else None diff --git a/storage/__init__.py b/storage/__init__.py index 69f26def4..354430603 100644 --- a/storage/__init__.py +++ b/storage/__init__.py @@ -39,7 +39,8 @@ class Storage(object): if not preference: preference = storages.keys() - d_storage = DistributedStorage(storages, preference) + default_locations = app.config.get('DISTRIBUTED_STORAGE_DEFAULT_LOCATIONS') or [] + d_storage = DistributedStorage(storages, preference, default_locations) # register extension with app app.extensions = getattr(app, 'extensions', {}) diff --git a/storage/basestorage.py b/storage/basestorage.py index 756734241..e085a5a08 100644 --- a/storage/basestorage.py +++ b/storage/basestorage.py @@ -98,6 +98,9 @@ class BaseStorage(StoragePaths): def get_checksum(self, path): raise NotImplementedError + def copy_to(self, destination, path): + raise NotImplementedError + class DigestInvalidException(RuntimeError): pass @@ -119,6 +122,3 @@ class BaseStorageV2(BaseStorage): """ Complete the chunked upload and store the final results in the path indicated. """ raise NotImplementedError - - - diff --git a/storage/cloud.py b/storage/cloud.py index 418a930ac..89ebf53aa 100644 --- a/storage/cloud.py +++ b/storage/cloud.py @@ -222,6 +222,28 @@ class _CloudStorage(BaseStorage): return k.etag[1:-1][:7] + def copy_to(self, destination, path): + # First try to copy directly via boto, but only if the storages are the + # same type, with the same access information. + if (self.__class__ == destination.__class__ and + self._access_key == destination._access_key and + self._secret_key == destination._secret_key): + logger.debug('Copying file from %s to %s via a direct boto copy', self._cloud_bucket, + destination._cloud_bucket) + + source_path = self._init_path(path) + source_key = self._key_class(self._cloud_bucket, source_path) + + dest_path = destination._init_path(path) + source_key.copy(destination._cloud_bucket, dest_path) + return + + # Fallback to a slower, default copy. + logger.debug('Copying file from %s to %s via a streamed copy', self._cloud_bucket, + destination) + with self.stream_read_file(path) as fp: + destination.stream_write(path, fp) + class S3Storage(_CloudStorage): def __init__(self, storage_path, s3_access_key, s3_secret_key, s3_bucket): @@ -252,7 +274,6 @@ class S3Storage(_CloudStorage): """) - class GoogleCloudStorage(_CloudStorage): def __init__(self, storage_path, access_key, secret_key, bucket_name): upload_params = {} diff --git a/storage/distributedstorage.py b/storage/distributedstorage.py index 26a0f5dbd..49f32c559 100644 --- a/storage/distributedstorage.py +++ b/storage/distributedstorage.py @@ -26,9 +26,15 @@ def _location_aware(unbound_func): class DistributedStorage(StoragePaths): - def __init__(self, storages, preferred_locations=[]): + def __init__(self, storages, preferred_locations=[], default_locations=[]): self._storages = dict(storages) self.preferred_locations = list(preferred_locations) + self.default_locations = list(default_locations) + + @property + def locations(self): + """ Returns the names of the locations supported. """ + return list(self._storages.keys()) get_direct_download_url = _location_aware(BaseStorage.get_direct_download_url) get_direct_upload_url = _location_aware(BaseStorage.get_direct_upload_url) @@ -42,6 +48,14 @@ class DistributedStorage(StoragePaths): remove = _location_aware(BaseStorage.remove) get_checksum = _location_aware(BaseStorage.get_checksum) get_supports_resumable_downloads = _location_aware(BaseStorage.get_supports_resumable_downloads) + initiate_chunked_upload = _location_aware(BaseStorageV2.initiate_chunked_upload) stream_upload_chunk = _location_aware(BaseStorageV2.stream_upload_chunk) complete_chunked_upload = _location_aware(BaseStorageV2.complete_chunked_upload) + + def copy_between(self, path, source_location, destination_location): + """ Copies a file between the source location and the destination location. """ + source_storage = self._storages[source_location] + destination_storage = self._storages[destination_location] + source_storage.copy_to(destination_storage, path) + diff --git a/storage/fakestorage.py b/storage/fakestorage.py index f351ca150..b4f27be32 100644 --- a/storage/fakestorage.py +++ b/storage/fakestorage.py @@ -1,4 +1,5 @@ from storage.basestorage import BaseStorage +from cStringIO import StringIO _FAKE_STORAGE_MAP = {} @@ -18,6 +19,9 @@ class FakeStorage(BaseStorage): def stream_read(self, path): yield _FAKE_STORAGE_MAP[path] + def stream_read_file(self, path): + return StringIO(_FAKE_STORAGE_MAP[path]) + def stream_write(self, path, fp, content_type=None, content_encoding=None): _FAKE_STORAGE_MAP[path] = fp.read() diff --git a/storage/local.py b/storage/local.py index fc2b79563..b2cbc458c 100644 --- a/storage/local.py +++ b/storage/local.py @@ -112,11 +112,9 @@ class LocalStorage(BaseStorageV2): sha_hash.update(buf) return sha_hash.hexdigest()[:7] - def _rel_upload_path(self, uuid): return 'uploads/{0}'.format(uuid) - def initiate_chunked_upload(self): new_uuid = str(uuid4()) @@ -162,3 +160,7 @@ class LocalStorage(BaseStorageV2): raise Exception('Storage path %s is not under a mounted volume.\n\n' 'Registry data must be stored under a mounted volume ' 'to prevent data loss' % self._root_path) + + def copy_to(self, destination, path): + with self.stream_read_file(path) as fp: + destination.stream_write(path, fp) diff --git a/test/data/test.db b/test/data/test.db index 45095cb99d321022fcb09778bd55a8c2ea21fdea..ec924222189c6f0386b5125c81985e123cddcdbb 100644 GIT binary patch delta 16056 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 delta 15519 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