From bf98575feb2152bb2315d57ad4fd8ba4fcad1dee Mon Sep 17 00:00:00 2001 From: Jake Moshenko Date: Tue, 17 Jun 2014 16:03:43 -0400 Subject: [PATCH] Add the basics of geographic data distribution and get the tests to work. --- config.py | 11 ++-- data/database.py | 18 +++++- data/model/legacy.py | 59 ++++++++++++------ endpoints/api/image.py | 2 +- endpoints/index.py | 4 +- endpoints/registry.py | 55 ++++++++-------- initdb.py | 9 ++- storage/__init__.py | 36 ++++++----- storage/basestorage.py | 54 ++++++++-------- storage/distributedstorage.py | 41 ++++++++++++ .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 .../diffs.json | 0 test/data/test.db | Bin 202752 -> 215040 bytes test/test_image_sharing.py | 6 +- test/testconfig.py | 3 +- 23 files changed, 198 insertions(+), 100 deletions(-) create mode 100644 storage/distributedstorage.py rename test/data/registry/{ => us}/sharedimages/08a8ab1f-4aaa-4337-88ab-5b5c71a8d492/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/2e3b616b-301f-437c-98ab-37352f444a60/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/4259533e-868d-4db3-9a78-fc24ffc03a2b/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/4a71f3db-cbb1-4c3b-858f-1be032b3e875/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/6fe6cebb-52b2-4036-892e-b86d6487a56b/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/8ec59952-8f5a-4fa0-897e-57c3337e1914/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/ab5160d1-8fb4-4022-a135-3c4de7f6ed97/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/c2c6dc6e-24d1-4f15-a616-81c41e3e3629/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/d40d531a-c70c-47f9-bf5b-2a4381db2d60/diffs.json (100%) rename test/data/registry/{ => us}/sharedimages/e969ff76-e87d-4ea3-8cb3-0db9b5bcb8d9/diffs.json (100%) diff --git a/config.py b/config.py index 2d267febe..79866aaf2 100644 --- a/config.py +++ b/config.py @@ -72,10 +72,6 @@ class DefaultConfig(object): # copies. USE_CDN = True - # Data storage - STORAGE_TYPE = 'LocalStorage' - STORAGE_PATH = 'test/data/registry' - # Authentication AUTHENTICATION_TYPE = 'Database' @@ -149,3 +145,10 @@ class DefaultConfig(object): # Feature Flag: Whether to support GitHub build triggers. FEATURE_GITHUB_BUILD = False + + DISTRIBUTED_STORAGE_CONFIG = { + 'local_eu': ['LocalStorage', 'test/data/registry/eu'], + 'local_us': ['LocalStorage', 'test/data/registry/us'], + } + + DISTRIBUTED_STORAGE_PREFERENCE = ['local_us'] diff --git a/data/database.py b/data/database.py index 9ec53435b..b24232ab3 100644 --- a/data/database.py +++ b/data/database.py @@ -222,6 +222,22 @@ class ImageStorage(BaseModel): uploading = BooleanField(default=True, null=True) +class ImageStorageLocation(BaseModel): + name = CharField(unique=True, index=True) + + +class ImageStoragePlacement(BaseModel): + storage = ForeignKeyField(ImageStorage) + location = ForeignKeyField(ImageStorageLocation) + + class Meta: + database = db + indexes = ( + # An image can only be placed in the same place once + (('storage', '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 @@ -341,4 +357,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission, RepositoryBuild, Team, TeamMember, TeamRole, Webhook, LogEntryKind, LogEntry, PermissionPrototype, ImageStorage, BuildTriggerService, RepositoryBuildTrigger, OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind, - Notification] + Notification, ImageStorageLocation, ImageStoragePlacement] diff --git a/data/model/legacy.py b/data/model/legacy.py index c7fd49b4c..85c5456a5 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -845,20 +845,28 @@ def get_repository(namespace_name, repository_name): def get_repo_image(namespace_name, repository_name, image_id): - query = (Image - .select(Image, ImageStorage) + location_list = list((ImageStoragePlacement + .select(ImageStoragePlacement, Image, ImageStorage, ImageStorageLocation) + .join(ImageStorageLocation) + .switch(ImageStoragePlacement) + .join(ImageStorage) + .join(Image) .join(Repository) - .switch(Image) - .join(ImageStorage, JOIN_LEFT_OUTER) .where(Repository.name == repository_name, Repository.namespace == namespace_name, - Image.docker_image_id == image_id)) + Image.docker_image_id == image_id))) - try: - return query.get() - except Image.DoesNotExist: + if not location_list: return None + location_names = {location.location.name for location in location_list} + + image = location_list[0].storage.image + image.storage = location_list[0].storage + image.storage.locations = location_names + + return image + def repository_is_public(namespace_name, repository_name): joined = Repository.select().join(Visibility) @@ -940,7 +948,7 @@ def create_repository(namespace, name, creating_user, visibility='private'): return repo -def __translate_ancestry(old_ancestry, translations, repository, username): +def __translate_ancestry(old_ancestry, translations, repository, username, preferred_location): if old_ancestry == '/': return '/' @@ -950,9 +958,8 @@ def __translate_ancestry(old_ancestry, translations, repository, username): # Figure out which docker_image_id the old id refers to, then find a # a local one old = Image.select(Image.docker_image_id).where(Image.id == old_id).get() - image_in_repo = find_create_or_link_image(old.docker_image_id, - repository, username, - translations) + image_in_repo = find_create_or_link_image(old.docker_image_id, repository, username, + translations, preferred_location) translations[old_id] = image_in_repo.id return translations[old_id] @@ -962,8 +969,8 @@ def __translate_ancestry(old_ancestry, translations, repository, username): return '/%s/' % '/'.join(new_ids) -def find_create_or_link_image(docker_image_id, repository, username, - translations): +def find_create_or_link_image(docker_image_id, repository, username, translations, + preferred_location): with config.app_config['DB_TRANSACTION_FACTORY'](db): repo_image = get_repo_image(repository.namespace, repository.name, docker_image_id) @@ -990,20 +997,29 @@ def find_create_or_link_image(docker_image_id, repository, username, msg = 'Linking image to existing storage with docker id: %s and uuid: %s' logger.debug(msg, docker_image_id, to_copy.storage.uuid) - new_image_ancestry = __translate_ancestry(to_copy.ancestors, - translations, repository, - username) + new_image_ancestry = __translate_ancestry(to_copy.ancestors, translations, repository, + username, preferred_location) storage = to_copy.storage + storage.locations = {placement.location.name + for placement in storage.imagestorageplacement_set} origin_image_id = to_copy.id except Image.DoesNotExist: logger.debug('Creating new storage for docker id: %s', docker_image_id) storage = ImageStorage.create() + location = ImageStorageLocation.get(name=preferred_location) + ImageStoragePlacement.create(location=location, storage=storage) + storage.locations = {preferred_location} + + logger.debug('Storage locations: %s', storage.locations) new_image = Image.create(docker_image_id=docker_image_id, repository=repository, storage=storage, ancestors=new_image_ancestry) + logger.debug('new_image storage locations: %s', new_image.storage.locations) + + if origin_image_id: logger.debug('Storing translation %s -> %s', origin_image_id, new_image.id) translations[origin_image_id] = new_image.id @@ -1130,9 +1146,14 @@ def garbage_collect_repository(namespace_name, repository_name): for storage in storage_to_remove: logger.debug('Garbage collecting image storage: %s', storage.uuid) - storage.delete_instance() + image_path = config.store.image_path(storage.uuid) - config.store.remove(image_path) + for placement in storage.imagestorageplacement_set: + location_name = placement.location.name + placement.delete_instance() + config.store.remove(location_name, image_path) + + storage.delete_instance() return len(to_remove) diff --git a/endpoints/api/image.py b/endpoints/api/image.py index 6593d18df..48b471c38 100644 --- a/endpoints/api/image.py +++ b/endpoints/api/image.py @@ -82,7 +82,7 @@ class RepositoryImageChanges(RepositoryParamResource): diffs_path = store.image_file_diffs_path(image.storage.uuid) try: - response_json = json.loads(store.get_content(diffs_path)) + response_json = json.loads(store.get_content(image.storage.locations, diffs_path)) return response_json except IOError: raise NotFound() diff --git a/endpoints/index.py b/endpoints/index.py index f1d6075f7..dd3033c57 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -8,7 +8,7 @@ from collections import OrderedDict from data import model from data.model import oauth -from app import analytics, app, webhook_queue, authentication, userevents +from app import analytics, app, webhook_queue, authentication, userevents, storage from auth.auth import process_auth from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from util.names import parse_repository_name @@ -228,7 +228,7 @@ def create_repository(namespace, repository): translations = {} for image_description in added_images.values(): model.find_create_or_link_image(image_description['id'], repo, username, - translations) + translations, storage.preferred_locations[0]) profile.debug('Created images') diff --git a/endpoints/registry.py b/endpoints/registry.py index 2a77e1ef9..9b1aacbe4 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -106,14 +106,14 @@ def get_image_layer(namespace, repository, image_id, headers): path = store.image_layer_path(repo_image.storage.uuid) profile.debug('Looking up the direct download URL') - direct_download_url = store.get_direct_download_url(path) + direct_download_url = store.get_direct_download_url(repo_image.storage.locations, path) if direct_download_url: profile.debug('Returning direct download URL') return redirect(direct_download_url) profile.debug('Streaming layer data') - return Response(store.stream_read(path), headers=headers) + return Response(store.stream_read(repo_image.storage.locations, path), headers=headers) except (IOError, AttributeError): profile.debug('Image not found') abort(404, 'Image %(image_id)s not found', issue='unknown-image', @@ -136,7 +136,7 @@ def put_image_layer(namespace, repository, image_id): try: profile.debug('Retrieving image data') uuid = repo_image.storage.uuid - json_data = store.get_content(store.image_json_path(uuid)) + json_data = store.get_content(repo_image.storage.locations, store.image_json_path(uuid)) except (IOError, AttributeError): abort(404, 'Image %(image_id)s not found', issue='unknown-image', image_id=image_id) @@ -144,7 +144,7 @@ def put_image_layer(namespace, repository, image_id): profile.debug('Retrieving image path info') layer_path = store.image_layer_path(uuid) - if (store.exists(layer_path) and not + if (store.exists(repo_image.storage.locations, layer_path) and not image_is_uploading(repo_image)): exact_abort(409, 'Image already exists') @@ -163,7 +163,7 @@ def put_image_layer(namespace, repository, image_id): sr.add_handler(store_hndlr) h, sum_hndlr = checksums.simple_checksum_handler(json_data) sr.add_handler(sum_hndlr) - store.stream_write(layer_path, sr) + store.stream_write(repo_image.storage.locations, layer_path, sr) csums.append('sha256:{0}'.format(h.hexdigest())) try: @@ -231,7 +231,7 @@ def put_image_checksum(namespace, repository, image_id): uuid = repo_image.storage.uuid profile.debug('Looking up repo layer data') - if not store.exists(store.image_json_path(uuid)): + if not store.exists(repo_image.storage.locations, store.image_json_path(uuid)): abort(404, 'Image not found: %(image_id)s', issue='unknown-image', image_id=image_id) profile.debug('Marking image path') @@ -283,7 +283,8 @@ def get_image_json(namespace, repository, image_id, headers): profile.debug('Looking up repo layer data') try: - data = store.get_content(store.image_json_path(repo_image.storage.uuid)) + uuid = repo_image.storage.uuid + data = store.get_content(repo_image.storage.locations, store.image_json_path(uuid)) except (IOError, AttributeError): flask_abort(404) @@ -313,7 +314,8 @@ def get_image_ancestry(namespace, repository, image_id, headers): profile.debug('Looking up image data') try: - data = store.get_content(store.image_ancestry_path(repo_image.storage.uuid)) + uuid = repo_image.storage.uuid + data = store.get_content(repo_image.storage.locations, store.image_ancestry_path(uuid)) except (IOError, AttributeError): abort(404, 'Image %(image_id)s not found', issue='unknown-image', image_id=image_id) @@ -326,17 +328,15 @@ def get_image_ancestry(namespace, repository, image_id, headers): return response -def generate_ancestry(image_id, uuid, parent_id=None, - parent_uuid=None): +def generate_ancestry(image_id, uuid, locations, parent_id=None, parent_uuid=None, + parent_locations=None): if not parent_id: - store.put_content(store.image_ancestry_path(uuid), - json.dumps([image_id])) + store.put_content(locations, store.image_ancestry_path(uuid), json.dumps([image_id])) return - data = store.get_content(store.image_ancestry_path(parent_uuid)) + data = store.get_content(parent_locations, store.image_ancestry_path(parent_uuid)) data = json.loads(data) data.insert(0, image_id) - store.put_content(store.image_ancestry_path(uuid), - json.dumps(data)) + store.put_content(locations, store.image_ancestry_path(uuid), json.dumps(data)) def store_checksum(image_storage, checksum): @@ -393,7 +393,7 @@ def put_image_json(namespace, repository, image_id): profile.debug('Looking up parent image data') if (parent_id and not - store.exists(store.image_json_path(parent_uuid))): + store.exists(parent_image.storage.locations, store.image_json_path(parent_uuid))): abort(400, 'Image %(image_id)s depends on non existing parent image %(parent_id)s', issue='invalid-request', image_id=image_id, parent_id=parent_id) @@ -401,7 +401,7 @@ def put_image_json(namespace, repository, image_id): json_path = store.image_json_path(uuid) profile.debug('Checking if image already exists') - if (store.exists(json_path) and not + if (store.exists(repo_image.storage.locations, json_path) and not image_is_uploading(repo_image)): exact_abort(409, 'Image already exists') @@ -424,10 +424,11 @@ def put_image_json(namespace, repository, image_id): parent_image) profile.debug('Putting json path') - store.put_content(json_path, request.data) + store.put_content(repo_image.storage.locations, json_path, request.data) profile.debug('Generating image ancestry') - generate_ancestry(image_id, uuid, parent_id, parent_uuid) + generate_ancestry(image_id, uuid, repo_image.storage.locations, parent_id, parent_uuid, + parent_image.storage.locations) profile.debug('Done') return make_response('true', 200) @@ -442,7 +443,7 @@ def process_image_changes(namespace, repository, image_id): image_diffs_path = store.image_file_diffs_path(uuid) image_trie_path = store.image_file_trie_path(uuid) - if store.exists(image_diffs_path): + if store.exists(repo_image.storage.locations, image_diffs_path): logger.debug('Diffs already exist for image: %s' % image_id) return image_trie_path @@ -452,18 +453,18 @@ def process_image_changes(namespace, repository, image_id): # Compute the diffs and fs for the parent first if necessary parent_trie_path = None if parents: - parent_trie_path = process_image_changes(namespace, repository, - parents[-1].docker_image_id) + parent_trie_path, parent_locations = process_image_changes(namespace, repository, + parents[-1].docker_image_id) # Read in the collapsed layer state of the filesystem for the parent parent_trie = changes.empty_fs() if parent_trie_path: - parent_trie_bytes = store.get_content(parent_trie_path) + parent_trie_bytes = store.get_content(parent_locations, parent_trie_path) parent_trie.frombytes(parent_trie_bytes) # Read in the file entries from the layer tar file layer_path = store.image_layer_path(uuid) - with store.stream_read_file(layer_path) as layer_tar_stream: + with store.stream_read_file(image.storage.locations, layer_path) as layer_tar_stream: removed_files = set() layer_files = changes.files_and_dirs_from_tar(layer_tar_stream, removed_files) @@ -473,7 +474,7 @@ def process_image_changes(namespace, repository, image_id): (new_trie, added, changed, removed) = new_metadata # Write out the new trie - store.put_content(image_trie_path, new_trie.tobytes()) + store.put_content(image.storage.locations, image_trie_path, new_trie.tobytes()) # Write out the diffs diffs = {} @@ -481,6 +482,6 @@ def process_image_changes(namespace, repository, image_id): for section, source_trie in zip(sections, new_metadata[1:]): diffs[section] = list(source_trie) diffs[section].sort() - store.put_content(image_diffs_path, json.dumps(diffs, indent=2)) + store.put_content(image.storage.locations, image_diffs_path, json.dumps(diffs, indent=2)) - return image_trie_path + return image_trie_path, image.storage.locations diff --git a/initdb.py b/initdb.py index a48cac4e3..cc1471e73 100644 --- a/initdb.py +++ b/initdb.py @@ -67,8 +67,8 @@ def __create_subtree(repo, structure, creator_username, parent): logger.debug('new docker id: %s' % docker_image_id) checksum = __gen_checksum(docker_image_id) - new_image = model.find_create_or_link_image(docker_image_id, repo, None, - {}) + new_image = model.find_create_or_link_image(docker_image_id, repo, None, {}, 'local_us') + new_image_locations = new_image.storage.locations new_image.storage.uuid = IMAGE_UUIDS[image_num % len(IMAGE_UUIDS)] new_image.storage.uploading = False new_image.storage.checksum = checksum @@ -89,7 +89,7 @@ def __create_subtree(repo, structure, creator_username, parent): source_diff = SAMPLE_DIFFS[image_num % len(SAMPLE_DIFFS)] with open(source_diff, 'r') as source_file: - store.stream_write(diff_path, source_file) + store.stream_write(new_image_locations, diff_path, source_file) parent = new_image @@ -235,6 +235,9 @@ def initialize_database(): NotificationKind.create(name='test_notification') + ImageStorageLocation.create(name='local_eu') + ImageStorageLocation.create(name='local_us') + def wipe_database(): logger.debug('Wiping all data from the DB.') diff --git a/storage/__init__.py b/storage/__init__.py index 7deaa0215..163a72355 100644 --- a/storage/__init__.py +++ b/storage/__init__.py @@ -1,6 +1,7 @@ from storage.local import LocalStorage from storage.s3 import S3Storage from storage.fakestorage import FakeStorage +from storage.distributedstorage import DistributedStorage class Storage(object): @@ -12,25 +13,32 @@ class Storage(object): self.state = None def init_app(self, app): - storage_type = app.config.get('STORAGE_TYPE', 'LocalStorage') - path = app.config.get('STORAGE_PATH', '') + # storage_type = app.config.get('STORAGE_TYPE', 'LocalStorage') + # path = app.config.get('STORAGE_PATH', '') - if storage_type == 'LocalStorage': - storage = LocalStorage(path) + storages = {} + for location, storage_params in app.config.get('DISTRIBUTED_STORAGE_CONFIG').items(): + driver = storage_params[0] - elif storage_type == 'S3Storage': - access_key = app.config.get('STORAGE_AWS_ACCESS_KEY', '') - secret_key = app.config.get('STORAGE_AWS_SECRET_KEY', '') - bucket = app.config.get('STORAGE_S3_BUCKET', '') - storage = S3Storage(path, access_key, secret_key, bucket) + if driver == 'LocalStorage': + storage = LocalStorage(*storage_params[1:]) + elif driver == 'S3Storage': + storage = S3Storage(*storage_params[1:]) + else: + storage = FakeStorage() - else: - storage = FakeStorage() + storages[location] = storage + + preference = app.config.get('DISTRIBUTED_STORAGE_PREFERENCE', None) + if not preference: + preference = storages.keys() + + d_storage = DistributedStorage(storages, preference) # register extension with app app.extensions = getattr(app, 'extensions', {}) - app.extensions['storage'] = storage - return storage + app.extensions['storage'] = d_storage + return d_storage def __getattr__(self, name): - return getattr(self.state, name, None) \ No newline at end of file + return getattr(self.state, name, None) diff --git a/storage/basestorage.py b/storage/basestorage.py index 9b43cb1ae..4494eb3b5 100644 --- a/storage/basestorage.py +++ b/storage/basestorage.py @@ -1,33 +1,8 @@ import tempfile -class BaseStorage(object): - - """Storage is organized as follow: - $ROOT/images//json - $ROOT/images//layer - $ROOT/repositories/// - """ - - # Useful if we want to change those locations later without rewriting - # the code which uses Storage - repositories = 'repositories' - images = 'images' +class StoragePaths(object): shared_images = 'sharedimages' - # Set the IO buffer to 64kB - buffer_size = 64 * 1024 - - @staticmethod - def temp_store_handler(): - tmpf = tempfile.TemporaryFile() - - def fn(buf): - try: - tmpf.write(buf) - except IOError: - pass - - return tmpf, fn def image_path(self, storage_uuid): return '{0}/{1}/'.format(self.shared_images, storage_uuid) @@ -52,6 +27,33 @@ class BaseStorage(object): base_path = self.image_path(storage_uuid) return '{0}diffs.json'.format(base_path) + +class BaseStorage(StoragePaths): + """Storage is organized as follow: + $ROOT/images//json + $ROOT/images//layer + $ROOT/repositories/// + """ + + # Useful if we want to change those locations later without rewriting + # the code which uses Storage + repositories = 'repositories' + images = 'images' + # Set the IO buffer to 64kB + buffer_size = 64 * 1024 + + @staticmethod + def temp_store_handler(): + tmpf = tempfile.TemporaryFile() + + def fn(buf): + try: + tmpf.write(buf) + except IOError: + pass + + return tmpf, fn + def get_direct_download_url(self, path, expires_in=60): return None diff --git a/storage/distributedstorage.py b/storage/distributedstorage.py new file mode 100644 index 000000000..796abdc2b --- /dev/null +++ b/storage/distributedstorage.py @@ -0,0 +1,41 @@ +import random +import logging + +from functools import wraps + +from storage.basestorage import StoragePaths, BaseStorage + + +logger = logging.getLogger(__name__) + + +def _location_aware(unbound_func): + @wraps(unbound_func) + def wrapper(self, locations, *args, **kwargs): + storage = None + for preferred in self.preferred_locations: + if preferred in locations: + storage = self._storages[preferred] + + if not storage: + storage = self._storages[random.sample(locations, 1)[0]] + + storage_func = getattr(storage, unbound_func.__name__) + return storage_func(*args, **kwargs) + return wrapper + + +class DistributedStorage(StoragePaths): + def __init__(self, storages, preferred_locations=[]): + self._storages = dict(storages) + self.preferred_locations = list(preferred_locations) + + get_direct_download_url = _location_aware(BaseStorage.get_direct_download_url) + get_content = _location_aware(BaseStorage.get_content) + put_content = _location_aware(BaseStorage.put_content) + stream_read = _location_aware(BaseStorage.stream_read) + stream_read_file = _location_aware(BaseStorage.stream_read_file) + stream_write = _location_aware(BaseStorage.stream_write) + list_directory = _location_aware(BaseStorage.list_directory) + exists = _location_aware(BaseStorage.exists) + remove = _location_aware(BaseStorage.remove) diff --git a/test/data/registry/sharedimages/08a8ab1f-4aaa-4337-88ab-5b5c71a8d492/diffs.json b/test/data/registry/us/sharedimages/08a8ab1f-4aaa-4337-88ab-5b5c71a8d492/diffs.json similarity index 100% rename from test/data/registry/sharedimages/08a8ab1f-4aaa-4337-88ab-5b5c71a8d492/diffs.json rename to test/data/registry/us/sharedimages/08a8ab1f-4aaa-4337-88ab-5b5c71a8d492/diffs.json diff --git a/test/data/registry/sharedimages/2e3b616b-301f-437c-98ab-37352f444a60/diffs.json b/test/data/registry/us/sharedimages/2e3b616b-301f-437c-98ab-37352f444a60/diffs.json similarity index 100% rename from test/data/registry/sharedimages/2e3b616b-301f-437c-98ab-37352f444a60/diffs.json rename to test/data/registry/us/sharedimages/2e3b616b-301f-437c-98ab-37352f444a60/diffs.json diff --git a/test/data/registry/sharedimages/4259533e-868d-4db3-9a78-fc24ffc03a2b/diffs.json b/test/data/registry/us/sharedimages/4259533e-868d-4db3-9a78-fc24ffc03a2b/diffs.json similarity index 100% rename from test/data/registry/sharedimages/4259533e-868d-4db3-9a78-fc24ffc03a2b/diffs.json rename to test/data/registry/us/sharedimages/4259533e-868d-4db3-9a78-fc24ffc03a2b/diffs.json diff --git a/test/data/registry/sharedimages/4a71f3db-cbb1-4c3b-858f-1be032b3e875/diffs.json b/test/data/registry/us/sharedimages/4a71f3db-cbb1-4c3b-858f-1be032b3e875/diffs.json similarity index 100% rename from test/data/registry/sharedimages/4a71f3db-cbb1-4c3b-858f-1be032b3e875/diffs.json rename to test/data/registry/us/sharedimages/4a71f3db-cbb1-4c3b-858f-1be032b3e875/diffs.json diff --git a/test/data/registry/sharedimages/6fe6cebb-52b2-4036-892e-b86d6487a56b/diffs.json b/test/data/registry/us/sharedimages/6fe6cebb-52b2-4036-892e-b86d6487a56b/diffs.json similarity index 100% rename from test/data/registry/sharedimages/6fe6cebb-52b2-4036-892e-b86d6487a56b/diffs.json rename to test/data/registry/us/sharedimages/6fe6cebb-52b2-4036-892e-b86d6487a56b/diffs.json diff --git a/test/data/registry/sharedimages/8ec59952-8f5a-4fa0-897e-57c3337e1914/diffs.json b/test/data/registry/us/sharedimages/8ec59952-8f5a-4fa0-897e-57c3337e1914/diffs.json similarity index 100% rename from test/data/registry/sharedimages/8ec59952-8f5a-4fa0-897e-57c3337e1914/diffs.json rename to test/data/registry/us/sharedimages/8ec59952-8f5a-4fa0-897e-57c3337e1914/diffs.json diff --git a/test/data/registry/sharedimages/ab5160d1-8fb4-4022-a135-3c4de7f6ed97/diffs.json b/test/data/registry/us/sharedimages/ab5160d1-8fb4-4022-a135-3c4de7f6ed97/diffs.json similarity index 100% rename from test/data/registry/sharedimages/ab5160d1-8fb4-4022-a135-3c4de7f6ed97/diffs.json rename to test/data/registry/us/sharedimages/ab5160d1-8fb4-4022-a135-3c4de7f6ed97/diffs.json diff --git a/test/data/registry/sharedimages/c2c6dc6e-24d1-4f15-a616-81c41e3e3629/diffs.json b/test/data/registry/us/sharedimages/c2c6dc6e-24d1-4f15-a616-81c41e3e3629/diffs.json similarity index 100% rename from test/data/registry/sharedimages/c2c6dc6e-24d1-4f15-a616-81c41e3e3629/diffs.json rename to test/data/registry/us/sharedimages/c2c6dc6e-24d1-4f15-a616-81c41e3e3629/diffs.json diff --git a/test/data/registry/sharedimages/d40d531a-c70c-47f9-bf5b-2a4381db2d60/diffs.json b/test/data/registry/us/sharedimages/d40d531a-c70c-47f9-bf5b-2a4381db2d60/diffs.json similarity index 100% rename from test/data/registry/sharedimages/d40d531a-c70c-47f9-bf5b-2a4381db2d60/diffs.json rename to test/data/registry/us/sharedimages/d40d531a-c70c-47f9-bf5b-2a4381db2d60/diffs.json diff --git a/test/data/registry/sharedimages/e969ff76-e87d-4ea3-8cb3-0db9b5bcb8d9/diffs.json b/test/data/registry/us/sharedimages/e969ff76-e87d-4ea3-8cb3-0db9b5bcb8d9/diffs.json similarity index 100% rename from test/data/registry/sharedimages/e969ff76-e87d-4ea3-8cb3-0db9b5bcb8d9/diffs.json rename to test/data/registry/us/sharedimages/e969ff76-e87d-4ea3-8cb3-0db9b5bcb8d9/diffs.json diff --git a/test/data/test.db b/test/data/test.db index 8c227a1ca31e2b9d9520bd068017f71610338b56..09fbf419a1a7adf0eefa9d31c87ab88082ade226 100644 GIT binary patch delta 12970 zcmcJV33wIN*~jnu&duCxkc1GDKz0(ckel3nVa=YKeP1CYcO?l)fGn)a^(qK%b(!jM zN3^yTQIu$+Z55Dem5RGo@w3H#wMwm(R;^fD`QCF95tS}aeZD+T{=d0roBy0S+nkw; z9tgkani<#3i^@`|R4>v0mwy|t%{5Y>_V*Y>LGjWhJmQ*b*X60DUD74vQNh1M|K-CJ zcJUC&g}o%3JS3ytBx}1!20BQ3+eqBaB&+I4I;u$;%SmdANXnLxEVq%^4J0Nli7uNY zcLB+Q`6S6nBy-|OA|gpbLP>&yW{|>okXjCg=gHIfqHv?+KemZNpB*Lnc9KWAr$@M_hqJ%ws>Fe{Zs$Pv z*hm2A!+DBcp4F3B1tJzO_T=3kxc4EG=v< zO=&JHsxD1QJ6{qdwWxS$AUsGDyPS&G_YHZxo6eW6d&oI>-t)@J+dVNg|1?WlN?q;w z&zzQ$mBtHA%bvP$x*xf)4rk(iC31U0*TsdhCe~_&?Wy0oz=+--F=>O%Q_n$I&HY3wxqQE>vctG{8N{DP9fi}(z8Wz zUtgUqEjy4Dtck57d&U34-j^43et-U<(XfAMaN6HFrTiO%=cddKRD@_^y;itb`X>%` zbf3?;`0jIZpSL!{d3)?TZ`402=HJ*or#^F2(#|^rs6z|!3YdN3H||}!k^)Ncn5w1Z@|lN41NKJ;ivF0 z?1y{cF1QV{8C(ljzz*=kW*C7%SPf2C0WDAm6;KRI!3ug<2pNz}!%V7j>Z2h4)*uN0 zw+P)Vbd%7HLN^E<6FMq%MCh>4^+MMPT`P1*=%CO6p=*Tp3+)ryD|EF`pI^W$)Fad_ z)Fsp@v`1*S&{aaagsv32LTIPZ4x#Nr+l00XZ4ufmv`J{A(1svCzxsOdqfTh8&>ErD zLaT&U3at?85Lzy@OlYal5~0OHi-Z;mEfBg~=rW;8g)Rw_{B-)o;>RMPcA++*R-qQ5 zW}zmbMxh3wdZ9X@`9kxAYK7(sT_`k1XtvNSp_!z9zy;z*hR}4O^M$4fO%<9VG+Ahp z&_tmLLgxvcD>PncoX}XIbA-kSoh>w4=q#=&7$ts03XKptQ|Jt#;p|Wqq)%NzkO)x= zm4u>Nf)GS8w|>m$)+eQpX>L6!h5THj1^^S94n(ShBV`pWafVd?L%rd7?7?4C>+i&Y zFeM^68)ovsL#a*9hj3+Ea{7$ObLSG}lq*I#c{oKG%t*k28OoLE*$^c%{4zbCvVNYP z3$v803>QQwS7hiRTEso`X}I$1jD--Xgf2*e*>`0xfIH*V2U67Padp<;Bd#k@&kk1I zK?@KFg^lnLZpL>cgS3}i?ZVzDWnpF(#3%km_#S~Q(*!mC}BmN5U2QxQXo;;S!6t5QiqSL9hyjKa(ej^ ztC|Sqv$zb=Du~7o9LK*))zZCm6=&iuE{+!`+}<7E<0}zB1rUp-P;qtn5jJdCIn)ct}-l?8%##AjhW0z}Qz=yo1iZ0iHgM(woU< ztTPqnD|Y9Sm3CxwxNn`8W;>ofUHQJ#eCJB_0Z)ipvs}6pcSzdt$X*lr z4b5$~qR!4XQ=!#fW+-)(*!21edvS4bSy@?qSz|?OWn*!DM{Q}lxvs9%WUeb~Yq#1< zDOY)GYf*!>KD<;Nm;sGd9hNek*;-oGT4gP3Gc}p&njB4LV^M2)TUAS|t+YzlVeV`< zwVTS?^ksD|mdcjK>XO3x22HiOw8_w3p&ae+Eod=Tv^TdmG#O2n+U9DVy|&y@ZfP-f zlo^e!#X4h4X?;y|W39txX{aeQHdyT)cD<1@*A+SJB}ER0Lm6IUE->la?A4W>bseUv zhPnnrQ%PM-V_9islcS@xy|qH$VYAtdP34Z(#)@L2sajvx-dI!B>1Z=`80tC;&E*Z_ z@2>eg^gCCJLy}bhff-@=Usa8t*Ufa*IB=?{ApEZN5dU2z>Hl3VO)X~E(o+-M zhk5uziACjNt&$QPDP6ocS$Wo(1sUU?IHMsrh5Bzc^CUylSrX{IJ9H(sAJ`=LV2y^@PRn*k~v3)V*h~Kq4>JMxR z4i3h*rAMV~Y5jOq@QcXqC_&ke-?dSz%Y*3N!GCw}O8j5lJ88P0?|SF3j2jC1xA#rl zwt4@eb>F!w_&Pq9->y=^x7)^3f>(ibaa+2wXGb0^9DiX)4nT(T`K3mBIN)%WcIi zHbZl(y}4H3YE%y1lA$Y6z3TtswOJlLsT;FD}SG2S?lsjsy zrXp*ras1#d-9U{b`L9szK&Ty=JY`?lEZXZi~lYv0JP*qjT81 zZtWtcXRvQ*l%6(6*RJsnQ4W(@ z?*lAUI_}qlna-Z9?7e>h?q3ep@jd&OsOSF&jt)Gu zA{_FRm4_CCO%$}{&z64vLhv zUAKDIvQLe3A|oG807 zo8Im+T0ADL#pttZO)k4zYxf%MT8qhPav9xjpUY}eZ%rFnOAq3UQqnd!2gbZ<7oKyQ z^_E3C-6DfMPj9lDjD`s{d>9sGP5DOf4Utd^%gIhW>yCtIs1_F3_aY$^3fM#>WJ4_v zS_*|MDGCfwH`x$H%QYxs+cM};WqUNtg?iSr0Mgj)84xmgG#X~9)Bh9q6LGLf@>@MV zi`nM&Xzd2CQERg6oLV~_YE2%G!Dsb4EncHR!h8%?K}!ftK%rKb-euM~&04d^XwjPV zMyuB5)a$iQr$ujbI$b8K(~MF+hzy{DP@To)u^X%=t=(pIYfWCaPiwQg3|foJYSd}m zcDvqYwNHLA4_*w0V)jBhRY=3s1)WNVS*$Vxj8MX_wiueuUT7qHG7}QnpQ0gz&0PRt zP|6l806R4E6m?L>9#{a2pk*rk3kyKQW@iGm54JRuHi=q!couVKLNqTwaq_pB@I@Rr z*xLn=0&NTnVI@?s?m~LXYv)af-CRhIe3k5}LW=KXKWm1#ADt9r=4*`Re= z%~ma0w0Vq_&|tDLM;R2R+uVAu$!zv&sqavkHafe_pd+(pyVKyYxQ$Myk=<7Y3F$76 z#i%!!Oj-)3mglAu+iYH!*5fnitxki>HT6rz%EFP*Qok?r6=;^Y2 zZlm7pbK6ZmH|r>eoOG{8=heA9R;}ITGf`_cdbLiA&8D^J3^td^t@n9!cJ`xks!yBG z=(X!jX063zF;jh-j9Qn=r_&m3Mz7nhH+bmBgaa-OhZ?rC8BFYpJ_w#X(hOUG`sVYk zkjf(asDb%fVK#L0mJr91`XOv8^zK&b!gV~^66iT6@>DBjv9wVn_1-op0_RtelTWn) zi=-1wr7|i)!*u;H zlYOs^OnSeTsgpX*=6*;6-{h_R5Ikc=M_qBTqpYp7xU;phy|%p8P*G7;tJl@)>_s&O zLzS(q&Q?)XUEf|&XKJn}Z!*_46xP(!ve{Z<@2qThGzWic6g2AdbY`R3YMnf@5e`cQ z-}MX=7tk7!diVX?Xqa3ruIBx1kW2kK&JQ~1V~71Pk9qvGQPak5@IwytPg2kuVd-9K zEhdjar`3CnG<{p#9<9?w6S>dlaT$CbyU*h^vB$4~KR`RX=Sr}_8qwLA_7;eo4800! z5IWeIn_xcMdNs_4!O7iM!-F9-BsAOvo;i>Sow?Ne7v0I>M=Gp0j*Pl(NGh0~7UO4`>U>ZZs zCKh(@H-arzgGI;wdM|totJwSdAcx(55P})C+NjCA`=AG@rCzB81M5acxsuq)#~^~4AE%mk@$3m~ z+vAV`o2Me_cm~^aGsLhrABPxlvp+u$X4oOG!<)b41+>LDXY%?NAPvFC9(jox%raWG?znPs{);SgPt!PY?3PaA;+qa_mOR6yG z8B*1A(&y4&r9Vr5l-`xD-5NHTb_(`NCC_Z2s6+iEKc$wedZ>wHKYtf^FMr$iLp{lz z{N>+msU$3xdE^U_{=G`&rNAG7;Eg>?0ga-}Y|`$Ei~Jo71p2g3^w zhhr54ADwzq3}btq!GfSp%6gFf04U}s>=efVwgd_Hv0WT@vsXBFvlNMNGaDcT-!xT( z#GH$yt2r z&t0KGqt4Z;tIif=hf&s_gp1&NUrlLLeiVB(iCm4buw^y^Qg&Ak?$a#%O7lxiXmyEJ(G*nxmAi1j$Bq_%v#TRrn3Q{jXTJ3yV-vgHxhrN?(P zX@!^nEl?K9_VrS}9R|Eqz5MbG-)9j<{6MY$U-erPCL9V;>#v&I#fF!XKkJ)s-WwR4 z$=({^{+5wH?MKhn26ASwV}sn^a`Kl6bFQB#z^6jg>dRgnVMi;--;x>G<$=Um?9fHr zUnTjA{QJm>fG>*m1h~H{^0(;75jIiH{pm`7_&n>XA%EIIWmlkV4%>H{Qe9e$JJhMK z%noJ|b>wepcK4$b^*9uy);z4MV8bosZ^09ZuLcI^vbV0}{#wbOeASt!0y**Q*j3zL z8~KAW|zJK9D5j6HY#B#@ZI4t<~dTSfjd7I_~E_!3#q_1s@K`7{0P z-VY{vxW9Sfm;aV^Imutnu7v9YWoc|*7UkRFqWX&)JmF&zZu~$>e{$C388@yCQse70 zss8%NU(ynDHl)(aB?IT`Gjmit>n{<7>>Jv}iX?yqty)!!h$zcD9g z2NHAGAszQOME+7QKHePgWwRbV_qUe(CF_e+6YH=eO1&^LMqyn6@;CpkU8e(Od2HVj z?(bsqmv{ZCcUi8{R?wLa)5?FM&Y=d&|N7T}u9&d?LCmA_`vPEoPGq-;O**DzV@lzJT$i7idykg=;e)|^$PQ1^CZzKEAAM-$9aM^VC zxt;7U-cw`?$d(U377ktslQ!L_MvNtocI&Weh zw+GBzw!O&sGHnE2n$U0kUU^UUf2%Q!(9p#>w8?FaGlI4G-ZSv)$U#+}u&t zKhj~+TT5DooYe!ShB{-1r83`^-%?`k+|brk(duaJX!7YgI?C0RR*x0c3_Co1BgMuR%dkzqp}BKIiD^^0$vI>V^wF&E?i1V{2vmI>#2{VAU4a@D|f1z0omZ z^)TgOq=#;E-|+e^WmR=!!>yY(>zuX54s&6hzHP0$cTJka7@qxK?018s*pi;BGY zW>4{eZi{2AsH3l;d_$X|xoU7EdQHErYN)obL^oPuE2^j~87p+MMdN=oNYP64yj8$$}U1v6}t7Ee#@w@NM zv&$wi!MuK;eS?09D45Fcq5U$PTe*J0{wvuDn_G*H&a_HE}w!4uTi zX8+^VJ{EC=`dUuOA3mD+IiIN({bAReZ1@=UwT0JT?G6mKPR|6-QD4(Ps=gtR(=t61 zJWqWs`or*NCtl$G(%wj2%#OZD{-V}BiBZ`CA;fynNzy?r-ML*WS*C-zI+% z>TPENgRbeB;J4&2{Vi2=Acvk~#7ywNyY_)KMbNjk)io+N+L@|^1eiN5KX z;1A?4=~usw4*0y&Gr{|Kw?F=5+TZziSVrtuVLz_JO?VOR!fWtGyaRuX58xws2%p9m z@O34)Qlu5vFboq6$8fKm2*F5<)cE-u;wX#~0kd$HU^GSx&c@k- zF&HB_2j>XJVys{s#tFt_yx?4%D>x752_|5IU?L_8CSj6bGA0YAV2WQHq++UI8m0-( z$N7Tkm@b%s8G;LNfnX+P3T9!JU^Zq8=3tKCLR=`Ai@AbY)C%Tdo?t%a3+m9X69;i@T0=%yM5_R<^qcC1GS7N8Avn1v}AhmjbJDmV?N;5|42ufq%Q zG(9;OV5-)(;#OgH3vLnIjGF~F;U>Y2xKVHeZV(*9F~LzB6&%44!C@R0T#xGo*Wo(B zwYXMr2!{j*aZqpo{R84)4XzRF$9};+>=W$8UcuG4TF{JUK?_<0t!Ndrp-s?^cELrs zNN_PO7Npgf;8I*FxD1yGF8AYdaZrE-f`wQpScFA_#aJv@f+d2bSSnbCWrF2cF6cmq zU%>7l)(bXZgJ2^z3N~SrU^6xgwqT23E4B)@ zVVht(whMM(hhQgm3a-Evf-7;QU>6Nwyr-_hRh)k4#%>YNgFS*ybPBrACFn-Cpa(sI zUi1q3(8o7nys9@;(r5H&ds2E^dP90qdPX`-FHIkm?veILH%Zq@mq`I>v$S67lblkA z)IfVCg_2#;(`(gKDPHnNNg)!@_g#O+lk|1h8~7r9+jSTpr7yeg!9Da{*R^;Vebu!Y z*V8v$PWmjO0V}YOzUR_oHhs+%k5Tk37rL0rSs?(b&s2X_om9O|7c=!Id)4%c!;7+q z(}S{`(~Yu=(}l8=(}{8qXAjEVoZTp|;#`Gt7iSmBD>+x9yn=HD%AK5@D0gsnpkHq1 z2kj`gakin{%Gru?3ug<;&793BH*q$h+{oF8asy`r%JrP}DA#e;p0m?bcQ7+>wL%Ec*6y*|5di7n*S&VWKXA#PUoP{VC za2BAvoO3zK%Q%;zyp(e(%1bzxpuCuKG0KZL7olwD^xILk@dF#mR!%F*7ETMwW==E8 zCQcK|MouHj22KOYdQLscI!+zR`JDME=W*tttmV|AoXeSu@IfF9;<#f(;l;?BKM>&l%4dqnMRFqRVQ&3LkOh!40GYREH&P0?G zI1^Bw$2kw>xtw!Rj#sPbgSB{+<9I+E%CVfWD9_=XgK`XK49c@PXQLd=8IAHR&RHl& zaYmsW$r*`q1ZM=wGdX9XJcDxv%Hf>hD2H)|p&ZH?igJkBPX{3=2lIenzA7MUP?kAq zl@P=kM30`FYLq2T31#F&3<9+(1dxvdbEJBebefhX@6i(Fby})CO-q(XXzB7}TEg5& zOPO7?q}fDEn_gPtv{T}#zwx->7|Abh<>hTfc?;(jls9v3MtKwGCX_dFZbW$l=LVF= zILA;P)hyvHIw zq{V2;!YaeWDXQb}y(+00Jusr$tIEB$Fp+iV;stE)Y5ci5_{PXTvEk2XnVM*MHY+eV zG`%74H{2Jw?6sBi0@;Jp&-9rR>;4>Xle*`gp7=XoNzc7B%+8MfgI22QKYsF2AaR%- zs-_kD#1}NsFa4hn_62&39ZPb zqR=k2o(RG!7(|KAC{e&gaSfO5dVK3HAiD0Zs|d=m+#cx%(~olGo{aRn@Y3cbfGxB6vYb) z-JnAEyc30|3B|MXP&`wQ;^}G>PnDzCSB&C`d=z_fP&}TA;op@#e)js z0|_YZi$(F9XcXHbQQRAWLJkWza3FFzMNw*b_4HWqA*(OJ1o`>tc~py!ywsEkzFVh<%ll1f;47aq z#ezmwi;Y`j)sLz()givBO)C8T5;jsAI+2#;iohPKMI$@JMDSO}?G)3%PyU0L0s-0rHxfh%p`Au5MRzm#cNGe7rgn z0_DJ(LKHyiC1ZRLbujW#?SBx9G&-s0+PANdl=5w~WKAAZu>=HUU|%Uqqx zpLQU5MJgO<3J9b3(_asS)?cJ))stg({z@IIyc<7N4EmkwSE^Sa9J=5&T0?KZ=Fy+} zFnR9ED2PyOx_RY{$>F}*Y2mPN7+9HnL1(+Yy-WFTl6-n)Dw2G=aw1Gty6aX&gH|qG zH4P%=rK@H^guHK66hz6#R|)j3GbhTO_a?xUfd}p#4=_pI|7#wm%C>D$TeqdbWSQB< zKs4Uw9$4eA*4$3(=$+J@fsnO&s;{J;tu58(Ru!1c_1RVBTz)}OPL9=-#q#+^zQAOv zVt7$sWGO1<>Z+h~ zF=RF9>-5$#b7^(9rH;2&H{>_uW1ouS}SABtb0 zE2x({*9)0>B@Ou%B?4* z6GiO7|LHz@Lmlk)AOZq}dFc|K@1_}|P{+V{QL=+TL8 z)@YPP0rLNkCL6X`@-H}+k{x_hSQ~OwU%|uFm!o+*f|mG5dCxX8Ov5nWO6FrsFCa5v zx)StHk#hRhx+(IGeW?&T@cOOt~dB1(rIo+E7_nRaKN##aPSh z#aj9BV4R#kSe8)SP@%7`Hj3G0`2}KrQDIJlKG$R{VJoYv*@lMdQhf=}=d%qp1BV9} z08EpozL)~>1H~_H1HTxJ)~o|5UhX(NVe8?EkSOms9VVw9#`VaH56^<6(fa^-{o!Ew z)K%YsKOcT8pj^)hZqDw~33jJLr+3>Jon7GFI=hW`+8s`tQ_u08uBGkiHfL*dTbFBD zS6BNIR~yH$dL3iXv1~NQrE`2bYfNQWj%D;)Pt1W>IrBscBoDNn_!uBgww_`^hfkj> z?>#jGX2@@x5+N1uqveaI0%XJKB#4)7r{_T0z>d?i)YJb9q62Tv4TJ=F-g`5F8J=k6 zdqHyM2Z8e0)4}q4?}hp$YP1Dt45Qq1YJwbmJ_@+u=Leq;lC9^H(H6I#HvvCz=)5mL zvi!>Xd7zh{{vb~N$NOV(9?maCqsu8uY5dU?`MBqtv7$m@Ka!VLwfS$ad+|5z$CX#Q%UzE*u!h#L;9)IOKqVJQWVH zV8)HXB=5y=NC7aCNG)Eo$a@S-CQdEHgNfX!g;dBMZt}jZg?9tWIo8IDZkwQUxjCm! zZ?HRcqQE zt*q2wcQHnqN~8Q#P~(qvV~o=#ij1gt>g*09T1M~00gRo+_%I2g%cwUw7#Bt3o=h2l z8^JMlPH%VFdA-iSFsx4RFqm|9x6!U+Z353}?7YM778vihGvN(Ce7CRXphXHs7IY~G z#*xBY+<;{$tIdX@?-m+DUde+f@+lAgWRe8}&<|!K}$lc~ZA54k*GGwaNi$P`$No0x92!U_%bLEtWGF?G9FS=^U&> zuhTPjw5>z0ScA1WM2E>`=NQuAgq&Ch8rJA`b2=w43Oc>bVb|F?r(I`a^rFD(1=eOV zkUu#=h}CnVC^{K*M}vSS)tlTpk#mVU!Q~ct)@^4@qLXkgm>p{~at_uc75}{Tn4?-<`xB$izTuPvSLlVlM`46t7DxECOU@abauVnpmW>w2ECKzTyDLQTy;Tm ztijIlPR4E2xttuXNDz2@x(x%wh9FLx%Vrb|B;O6@SYFS$j4lI45pTp5iMV#r!5Vb} z!wU?;IB{iUpBo}$oycrtS(DCSGvbPz7-}}w$?I6XsJ97jC+BcE$OSj#$BJkRqaZkS zMu)+u*b}W}LQ}DJo6CeA#R@!6tP3G2*6nmMXeUu;aN%O~E<`Zd&`OM(VFjb$;B9)A zJhl++X%jgE!x_;)PSK764PZ0jJ8(HMGSEG7R+ncHtP6xZvc3!SP~$}(kN_j^bPr4; zAsf-b+&vHmb&4xQkSUuWV5D_-4~B4o(rp&h|3G=E2ZtDXQCUcudLav}-z&YxdVz$X zw+~O=w*e+Y!*|+o)0@jU~dg!Ho;i3xfgjFNA+RfDe}?# zS8Re9nD2dX6Zi$qEiJcH6;@T;A>b;c0)U zCKb;^9=HkK#*stMLnhQH-7K(Zq-n`PEKs#%??KF2%_B`G55gD{HHg`wjua2#n_E26 z-aZJS-jjonHTL`JfaWtI8%v^x;N2fJ8+gpUB;fc@ngyfDU?5kH!@tnSK0X0SN4VGeG1fi9wV;WGv_Jr0d_Hg^8Knu>=N zO6LMiqtjQb!#1-IH3e1?n-3*wY%Yy94cm73P|@(Kk*B@uL&Z(Zb8EC(Ev#)F-T^hY z+7`No#Vy{6z7zwRx`n>lWG#?#4K)c?lNJqS)HE!@2VmP?4K*41ysv7gKl=84w^uNw zVrparA#e(9hM&jUb#=fmtH=DZ+W)8OzLEXQ_1XP?s{gTC^FuXbyuQu3KHB$3YQs;} zJ~vSJ;pXZ3aP^P$!cT|OzpSRBXtV!yyD>i-t(^M>(66CX{f_>czCwRSe?tGA-m)sd z8xun9rE^~FLDh4MQ4A=@fBnj#-(z_w9#xLe9>zl=)%}SmemxDv-C7iz11Xdgy%(kG zAbpzNI}n=k5DkxzC$&_pdV31pNVY#jC3>%EsX#w?*&7&56@lNOkavg0Ev)29K) zddaS7s5w9`DVM!uRt#QtlMTw{A@ZhjAtZV_Ubd1Jy!dSynSv&^xm2e2+H@)pFahPo zQE{+_w8l|pOgsDIC;_Cc(>mqLunH#;41+P<8uv z^N5&s7H3fDutrR)) zD{3}*w2$@2o1kfCPe3$yswN>Ss;kw+B1#mE=2>AuC1 zJCro-Q23T0pZb--$2~0yA3OE&ZKSak`Hb!df+WiXvM+(2P1dzhx2wf}zdDlywwW@Ueei(eEgNB4N~hQa<*UL>p{N6EtfK+q>1FzK83Fr`4Z+Ytn@6W zj`^q)ZACAUp<9qIP^a4>MNJ_`o>BN#EB1T&O1k8ZB2CXKe0|6#emiu?vqs@lJy!k( zX_SzU9~`75OAP7H$8opciuQvN<|`6>8+C!6b?_VSwcDt6pHPjkt4nH&4^Y)eiTpH4 zrzTCwG*1p}r1ojXee!LC)H;KlDOQL#A+d47nJ_6Sj+`t}i0?q+*-uWs+jFP##uKkj z`H~FXg~W5t&PbG^lE{&Ag?KX(rw(jBC%F?zQ-wl&Hxh^5Lm&3sLs>)BNe@l?gf#9z z;<0PERLPP``sXRcJCQiyU&p7C;71jUHl~mB?4sI0lU(zVMrz|pPa@j=z;5JZ2OD3N zS~+rNk;3^~Yhi1#Z#dijLld5W_7s7-%+7)ax@NUXp8zGadn zo%DAp#2zGO?|Iotg1tzrza0IJ=Q+g(lrsp@`6Bv&_(giD)S59GLx+$woE~~zN}4?y zLx+(xbf2B~98oOF6#H%_LoZQ#eK!y7lA^LkL+E#iHzhLd4#{mE4WXlmm;Ave?|5ET z@S;CQns+Pwz_-ak_J9MJDa@yCY2yqY$am-N~Vf)RrECUSOwhK zcBhtAbTsr7E^2c&*E3noxvYilYOS+X78JPMty~UMMfkU<6n#f;cXQ{m;*NYrk>FmQ zZBET~n2J*?Oj?`YW#nqNoC^*Hk){c(UMKw0T(V1OJlx!AVmWsJE~ZxbsE3T@&O3Jm$?qW0yzMc#Mcq1MrvH+v9F9{DpB zo%uhVp8w$R-18SD)MB3e<_Z})gQ-CC#BH}oQKh5h;4H=(v&ZF<+<1sFTn^4*tm$(< zyWjJkl5REMrnivB^GYhX^REGtrBW#emvv0*`J?6F3i5@u2U|(- z{~@3MXWUDkFOe@aYW2tSr8dXU$^p>(G;rIM0jbqKS`H|>UlZj#wnR#@jg|wNUaXFP zqFLop(^$a9ng>piA%A+0CiKHapGr~g(Q+_`Zq!WM8F*B3yGF}F0KHpn96CDaiKLNu z%I}Z9L>i;$4>bu}-a0N>7LS&LDfBvZM(>X0BzP))L7h<)`?e>V_Vpva^QhgVKY>1~ zj_KZBOO_?l7c|uFGk=j<+eQk)t|TN45}x^^l+-#>5aeWq)aM)7lR_T@&9p|u^2CZp@N?M^5fedo*OnQ+z zhHo>_Fc`up$B>)UW*%cS#e(0tZRClJO=tI3C%bixff;J8V|WTvxjpu@q5P&g)$!Yn#r9P_hp2{KI6pG7C#NcoQ}x@z3dzX6mP!-+kcPQ8(GXErU~ Zpxt{Rn=YhpfSsR5Z=Lbq96iL`^8Zc_#Bu-t diff --git a/test/test_image_sharing.py b/test/test_image_sharing.py index bdc85cedf..ab278f9f4 100644 --- a/test/test_image_sharing.py +++ b/test/test_image_sharing.py @@ -3,7 +3,7 @@ import json as py_json from flask import url_for from endpoints.api import api -from app import app +from app import app, storage from initdb import setup_database_for_testing, finished_database_for_testing from data import model @@ -43,7 +43,9 @@ class TestImageSharing(unittest.TestCase): def createStorage(self, docker_image_id, repository=REPO, username=ADMIN_ACCESS_USER): repository_obj = model.get_repository(repository.split('/')[0], repository.split('/')[1]) - image = model.find_create_or_link_image(docker_image_id, repository_obj, username, {}) + preferred = storage.preferred_locations[0] + image = model.find_create_or_link_image(docker_image_id, repository_obj, username, {}, + preferred) return image.storage.id def assertSameStorage(self, docker_image_id, storage_id, repository=REPO, username=ADMIN_ACCESS_USER): diff --git a/test/testconfig.py b/test/testconfig.py index 4aa289aec..b7d79767e 100644 --- a/test/testconfig.py +++ b/test/testconfig.py @@ -24,7 +24,8 @@ class TestConfig(DefaultConfig): DB_TRANSACTION_FACTORY = create_transaction - STORAGE_TYPE = 'FakeStorage' + DISTRIBUTED_STORAGE_CONFIG = {'local_us': ['FakeStorage']} + DISTRIBUTED_STORAGE_PREFERENCE = ['local_us'] BUILDLOGS_MODULE_AND_CLASS = ('test.testlogs', 'testlogs.TestBuildLogs') BUILDLOGS_OPTIONS = ['devtable', 'building', 'deadbeef-dead-beef-dead-beefdeadbeef']