diff --git a/app.py b/app.py index 3be745f4a..77dd0c689 100644 --- a/app.py +++ b/app.py @@ -6,14 +6,12 @@ from flask import Flask, Config, request, Request, _request_ctx_stack from flask.ext.principal import Principal from flask.ext.login import LoginManager, UserMixin from flask.ext.mail import Mail +from werkzeug.routing import BaseConverter import features from avatars.avatars import Avatar from storage import Storage - -from avatars.avatars import Avatar - from data import model from data import database from data.userfiles import Userfiles @@ -45,6 +43,15 @@ CONFIG_PROVIDER = FileConfigProvider(OVERRIDE_CONFIG_DIRECTORY, 'config.yaml', ' app = Flask(__name__) logger = logging.getLogger(__name__) +class RegexConverter(BaseConverter): + def __init__(self, url_map, *items): + super(RegexConverter, self).__init__(url_map) + logger.debug('Installing regex converter with regex: %s', items[0]) + self.regex = items[0] + + +app.url_map.converters['regex'] = RegexConverter + # Instantiate the default configuration (for test or for normal operation). if 'TEST' in os.environ: CONFIG_PROVIDER = TestConfigProvider() diff --git a/auth/jwt_auth.py b/auth/jwt_auth.py new file mode 100644 index 000000000..e8b568691 --- /dev/null +++ b/auth/jwt_auth.py @@ -0,0 +1,87 @@ +import logging +import jwt +import re + +from datetime import datetime, timedelta +from functools import wraps +from flask import request +from flask.ext.principal import identity_changed, Identity +from cryptography.x509 import load_pem_x509_certificate +from cryptography.hazmat.backends import default_backend + +from app import app +from auth_context import set_grant_user_context +from permissions import repository_read_grant, repository_write_grant +from util.names import parse_namespace_repository + + +logger = logging.getLogger(__name__) + + +TOKEN_REGEX = re.compile(r'Bearer (([a-zA-Z0-9+/]+\.)+[a-zA-Z0-9+-_/]+)') + + +def process_jwt_auth(func): + @wraps(func) + def wrapper(*args, **kwargs): + logger.debug('Called with params: %s, %s', args, kwargs) + auth = request.headers.get('authorization', '').strip() + if auth: + logger.debug('Validating auth header: %s', auth) + + # Extract the jwt token from the header + match = TOKEN_REGEX.match(auth) + if match is None or match.end() != len(auth): + logger.debug('Not a valid bearer token: %s', auth) + return + + encoded = match.group(1) + logger.debug('encoded JWT: %s', encoded) + + # Load the JWT returned. + try: + with open('/Users/jake/Projects/registry-v2/ca/quay.host.crt') as cert_file: + cert_obj = load_pem_x509_certificate(cert_file.read(), default_backend()) + public_key = cert_obj.public_key() + payload = jwt.decode(encoded, public_key, algorithms=['RS256'], audience='quay', + issuer='token-issuer') + except jwt.InvalidTokenError: + logger.exception('Exception when decoding returned JWT') + return (None, 'Invalid username or password') + + if not 'sub' in payload: + raise Exception('Missing subject field in JWT') + + if not 'exp' in payload: + raise Exception('Missing exp field in JWT') + + # Verify that the expiration is no more than 300 seconds in the future. + if datetime.fromtimestamp(payload['exp']) > datetime.utcnow() + timedelta(seconds=300): + logger.debug('Payload expiration is outside of the 300 second window: %s', payload['exp']) + return (None, 'Invalid username or password') + + username = payload['sub'] + loaded_identity = Identity(username, 'signed_grant') + + # Process the grants from the payload + if 'access' in payload: + for grant in payload['access']: + if grant['type'] != 'repository': + continue + + namespace, repo_name = parse_namespace_repository(grant['name']) + + if 'push' in grant['actions']: + loaded_identity.provides.add(repository_write_grant(namespace, repo_name)) + elif 'pull' in grant['actions']: + loaded_identity.provides.add(repository_read_grant(namespace, repo_name)) + + identity_changed.send(app, identity=loaded_identity) + set_grant_user_context(username) + + logger.debug('Identity changed to %s', username) + else: + logger.debug('No auth header.') + + return func(*args, **kwargs) + return wrapper \ No newline at end of file diff --git a/data/database.py b/data/database.py index ce60de5a6..069081f68 100644 --- a/data/database.py +++ b/data/database.py @@ -514,6 +514,11 @@ class RepositoryTag(BaseModel): ) +class TagManifest(BaseModel): + tag = ForeignKeyField(RepositoryTag) + digest = CharField(index=True) + + class BUILD_PHASE(object): """ Build phases enum """ ERROR = 'error' diff --git a/endpoints/v1/__init__.py b/endpoints/v1/__init__.py new file mode 100644 index 000000000..6c4efdb2a --- /dev/null +++ b/endpoints/v1/__init__.py @@ -0,0 +1,29 @@ +from flask import Blueprint, make_response + +from endpoints.decorators import anon_protect, anon_allowed + + +v1_bp = Blueprint('v1', __name__) + + +# Note: This is *not* part of the Docker index spec. This is here for our own health check, +# since we have nginx handle the _ping below. +@v1_bp.route('/_internal_ping') +@anon_allowed +def internal_ping(): + return make_response('true', 200) + + +@v1_bp.route('/_ping') +@anon_allowed +def ping(): + # NOTE: any changes made here must also be reflected in the nginx config + response = make_response('true', 200) + response.headers['X-Docker-Registry-Version'] = '0.6.0' + response.headers['X-Docker-Registry-Standalone'] = '0' + return response + + +from endpoints.v1 import index +from endpoints.v1 import registry +from endpoints.v1 import tags \ No newline at end of file diff --git a/endpoints/index.py b/endpoints/v1/index.py similarity index 90% rename from endpoints/index.py rename to endpoints/v1/index.py index c6bbebbe5..621c92abe 100644 --- a/endpoints/index.py +++ b/endpoints/v1/index.py @@ -18,15 +18,15 @@ from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission, AlwaysFailPermission, repository_read_grant, repository_write_grant) from util.http import abort +from endpoints.v1 import v1_bp from endpoints.trackhelper import track_and_log from endpoints.notificationhelper import spawn_notification from endpoints.decorators import anon_protect, anon_allowed import features -logger = logging.getLogger(__name__) -index = Blueprint('index', __name__) +logger = logging.getLogger(__name__) class GrantType(object): @@ -73,8 +73,8 @@ def generate_headers(scope=GrantType.READ_REPOSITORY, add_grant_for_status=None) return decorator_method -@index.route('/users', methods=['POST']) -@index.route('/users/', methods=['POST']) +@v1_bp.route('/users', methods=['POST']) +@v1_bp.route('/users/', methods=['POST']) @anon_allowed def create_user(): user_data = request.get_json() @@ -123,8 +123,8 @@ def create_user(): abort(400, error_message, issue='login-failure') -@index.route('/users', methods=['GET']) -@index.route('/users/', methods=['GET']) +@v1_bp.route('/users', methods=['GET']) +@v1_bp.route('/users/', methods=['GET']) @process_auth @anon_allowed def get_user(): @@ -146,7 +146,7 @@ def get_user(): abort(404) -@index.route('/users//', methods=['PUT']) +@v1_bp.route('/users//', methods=['PUT']) @process_auth @anon_allowed def update_user(username): @@ -172,7 +172,7 @@ def update_user(username): abort(403) -@index.route('/repositories/', methods=['PUT']) +@v1_bp.route('/repositories/', methods=['PUT']) @process_auth @parse_repository_name @generate_headers(scope=GrantType.WRITE_REPOSITORY, add_grant_for_status=201) @@ -227,7 +227,7 @@ def create_repository(namespace, repository): return make_response('Created', 201) -@index.route('/repositories//images', methods=['PUT']) +@v1_bp.route('/repositories//images', methods=['PUT']) @process_auth @parse_repository_name @generate_headers(scope=GrantType.WRITE_REPOSITORY) @@ -260,7 +260,7 @@ def update_images(namespace, repository): abort(403) -@index.route('/repositories//images', methods=['GET']) +@v1_bp.route('/repositories//images', methods=['GET']) @process_auth @parse_repository_name @generate_headers(scope=GrantType.READ_REPOSITORY) @@ -286,7 +286,7 @@ def get_repository_images(namespace, repository): abort(403) -@index.route('/repositories//images', methods=['DELETE']) +@v1_bp.route('/repositories//images', methods=['DELETE']) @process_auth @parse_repository_name @generate_headers(scope=GrantType.WRITE_REPOSITORY) @@ -295,14 +295,14 @@ def delete_repository_images(namespace, repository): abort(501, 'Not Implemented', issue='not-implemented') -@index.route('/repositories//auth', methods=['PUT']) +@v1_bp.route('/repositories//auth', methods=['PUT']) @parse_repository_name @anon_allowed def put_repository_auth(namespace, repository): abort(501, 'Not Implemented', issue='not-implemented') -@index.route('/search', methods=['GET']) +@v1_bp.route('/search', methods=['GET']) @process_auth @anon_protect def get_search(): @@ -337,20 +337,3 @@ def get_search(): resp = make_response(json.dumps(data), 200) resp.mimetype = 'application/json' return resp - -# Note: This is *not* part of the Docker index spec. This is here for our own health check, -# since we have nginx handle the _ping below. -@index.route('/_internal_ping') -@anon_allowed -def internal_ping(): - return make_response('true', 200) - -@index.route('/_ping') -@index.route('/_ping') -@anon_allowed -def ping(): - # NOTE: any changes made here must also be reflected in the nginx config - response = make_response('true', 200) - response.headers['X-Docker-Registry-Version'] = '0.6.0' - response.headers['X-Docker-Registry-Standalone'] = '0' - return response diff --git a/endpoints/registry.py b/endpoints/v1/registry.py similarity index 97% rename from endpoints/registry.py rename to endpoints/v1/registry.py index 2cb6174e4..a425ef88e 100644 --- a/endpoints/registry.py +++ b/endpoints/v1/registry.py @@ -16,13 +16,13 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission) from data import model, database from util import gzipstream +from endpoints.v1 import v1_bp from endpoints.decorators import anon_protect -registry = Blueprint('registry', __name__) - logger = logging.getLogger(__name__) + class SocketReader(object): def __init__(self, fp): self._fp = fp @@ -93,7 +93,7 @@ def set_cache_headers(f): return wrapper -@registry.route('/images//layer', methods=['HEAD']) +@v1_bp.route('/images//layer', methods=['HEAD']) @process_auth @extract_namespace_repo_from_session @require_completion @@ -127,7 +127,7 @@ def head_image_layer(namespace, repository, image_id, headers): abort(403) -@registry.route('/images//layer', methods=['GET']) +@v1_bp.route('/images//layer', methods=['GET']) @process_auth @extract_namespace_repo_from_session @require_completion @@ -171,7 +171,7 @@ def get_image_layer(namespace, repository, image_id, headers): abort(403) -@registry.route('/images//layer', methods=['PUT']) +@v1_bp.route('/images//layer', methods=['PUT']) @process_auth @extract_namespace_repo_from_session @anon_protect @@ -277,7 +277,7 @@ def put_image_layer(namespace, repository, image_id): return make_response('true', 200) -@registry.route('/images//checksum', methods=['PUT']) +@v1_bp.route('/images//checksum', methods=['PUT']) @process_auth @extract_namespace_repo_from_session @anon_protect @@ -352,7 +352,7 @@ def put_image_checksum(namespace, repository, image_id): return make_response('true', 200) -@registry.route('/images//json', methods=['GET']) +@v1_bp.route('/images//json', methods=['GET']) @process_auth @extract_namespace_repo_from_session @require_completion @@ -384,7 +384,7 @@ def get_image_json(namespace, repository, image_id, headers): return response -@registry.route('/images//ancestry', methods=['GET']) +@v1_bp.route('/images//ancestry', methods=['GET']) @process_auth @extract_namespace_repo_from_session @require_completion @@ -438,7 +438,7 @@ def store_checksum(image_storage, checksum): image_storage.save() -@registry.route('/images//json', methods=['PUT']) +@v1_bp.route('/images//json', methods=['PUT']) @process_auth @extract_namespace_repo_from_session @anon_protect diff --git a/endpoints/tags.py b/endpoints/v1/tags.py similarity index 89% rename from endpoints/tags.py rename to endpoints/v1/tags.py index 4b2ce90b6..0b5df9f8e 100644 --- a/endpoints/tags.py +++ b/endpoints/v1/tags.py @@ -11,14 +11,13 @@ from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission) from data import model from endpoints.decorators import anon_protect +from endpoints.v1 import v1_bp logger = logging.getLogger(__name__) -tags = Blueprint('tags', __name__) - -@tags.route('/repositories//tags', +@v1_bp.route('/repositories//tags', methods=['GET']) @process_auth @anon_protect @@ -34,7 +33,7 @@ def get_tags(namespace, repository): abort(403) -@tags.route('/repositories//tags/', +@v1_bp.route('/repositories//tags/', methods=['GET']) @process_auth @anon_protect @@ -51,7 +50,7 @@ def get_tag(namespace, repository, tag): abort(403) -@tags.route('/repositories//tags/', +@v1_bp.route('/repositories//tags/', methods=['PUT']) @process_auth @anon_protect @@ -74,7 +73,7 @@ def put_tag(namespace, repository, tag): abort(403) -@tags.route('/repositories//tags/', +@v1_bp.route('/repositories//tags/', methods=['DELETE']) @process_auth @anon_protect diff --git a/endpoints/v2/__init__.py b/endpoints/v2/__init__.py new file mode 100644 index 000000000..cfad13224 --- /dev/null +++ b/endpoints/v2/__init__.py @@ -0,0 +1,62 @@ +import logging + +from flask import Blueprint, make_response +from functools import wraps + +from endpoints.decorators import anon_protect, anon_allowed +from auth.jwt_auth import process_jwt_auth +from auth.auth_context import get_grant_user_context +from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission, + AdministerRepositoryPermission) +from data import model +from util.http import abort + + + +logger = logging.getLogger(__name__) +v2_bp = Blueprint('v2', __name__) + + +def _require_repo_permission(permission_class, allow_public=False): + def wrapper(func): + @wraps(func) + def wrapped(namespace, repo_name, *args, **kwargs): + logger.debug('Checking permission %s for repo: %s/%s', permission_class, namespace, repo_name) + permission = permission_class(namespace, repo_name) + if (permission.can() or + (allow_public and + model.repository_is_public(namespace, repo_name))): + return func(namespace, repo_name, *args, **kwargs) + raise abort(401) + return wrapped + return wrapper + + +require_repo_read = _require_repo_permission(ReadRepositoryPermission, True) +require_repo_write = _require_repo_permission(ModifyRepositoryPermission) +require_repo_admin = _require_repo_permission(AdministerRepositoryPermission) + + +def get_input_stream(flask_request): + if flask_request.headers.get('transfer-encoding') == 'chunked': + return flask_request.environ['wsgi.input'] + return flask_request.stream + + +@v2_bp.route('/') +@process_jwt_auth +@anon_allowed +def v2_support_enabled(): + response = make_response('true', 200) + + if get_grant_user_context() is None: + response = make_response('true', 401) + response.headers['WWW-Authenticate'] = 'Bearer realm="192.168.59.3:5000/v2/auth",service="quay"' + + response.headers['Docker-Distribution-API-Version'] = 'registry/2.0' + return response + + +from endpoints.v2 import v2auth +from endpoints.v2 import manifest +from endpoints.v2 import blobs diff --git a/endpoints/v2/blobs.py b/endpoints/v2/blobs.py new file mode 100644 index 000000000..8ce69beaf --- /dev/null +++ b/endpoints/v2/blobs.py @@ -0,0 +1,20 @@ +import logging + +from flask import make_response + +from endpoints.v2 import v2_bp, require_repo_read, require_repo_write, require_repo_admin +from auth.jwt_auth import process_jwt_auth +from auth.permissions import ReadRepositoryPermission +from endpoints.decorators import anon_protect + + +logger = logging.getLogger(__name__) + + +@v2_bp.route('///blobs/', methods=['HEAD']) +@process_jwt_auth +@require_repo_read +@anon_protect +def check_blob_existence(namespace, repo_name, tarsum): + logger.debug('Fetching blob with tarsum: %s', tarsum) + return make_response('Blob {0}'.format(tarsum)) diff --git a/endpoints/v2/digest_tools.py b/endpoints/v2/digest_tools.py new file mode 100644 index 000000000..0857286fe --- /dev/null +++ b/endpoints/v2/digest_tools.py @@ -0,0 +1,46 @@ +import re +import os.path +import hashlib + +from collections import namedtuple + + +Digest = namedtuple('Digest', ['is_tarsum', 'tarsum_version', 'hash_alg', 'hash_bytes']) + + +DIGEST_PATTERN = r'(tarsum\.(v[\w]+)\+)?([\w]+):([0-9a-f]+)' +DIGEST_REGEX = re.compile(DIGEST_PATTERN) + + +class InvalidDigestException(RuntimeError): + pass + + +def parse_digest(digest): + """ Returns the digest parsed out to its components. """ + match = DIGEST_REGEX.match(digest) + if match is None or match.end() != len(digest): + raise InvalidDigestException('Not a valid digest: %s', digest) + + is_tarsum = match.group(1) is not None + return Digest(is_tarsum, match.group(2), match.group(3), match.group(4)) + + +def content_path(digest): + """ Returns a relative path to the parsed digest. """ + parsed = parse_digest(digest) + components = [] + + if parsed.is_tarsum: + components.extend(['tarsum', parsed.tarsum_version]) + + prefix = parsed.hash_bytes[0:2].zfill(2) + components.extend([parsed.hash_alg, prefix, parsed.hash_bytes]) + + return os.path.join(*components) + + +def sha256_digest(content): + """ Returns a sha256 hash of the content bytes in digest form. """ + digest = hashlib.sha256(content) + return 'sha256:{0}'.format(digest.hexdigest()) diff --git a/endpoints/v2/manifest.py b/endpoints/v2/manifest.py new file mode 100644 index 000000000..2d6c7b39c --- /dev/null +++ b/endpoints/v2/manifest.py @@ -0,0 +1,63 @@ +import logging +import re +import hashlib + +from flask import make_response, request + +from app import storage +from auth.jwt_auth import process_jwt_auth +from auth.permissions import ReadRepositoryPermission +from endpoints.decorators import anon_protect +from endpoints.v2 import (v2_bp, require_repo_read, require_repo_write, require_repo_admin, + get_input_stream) +from endpoints.v2 import digest_tools + + +logger = logging.getLogger(__name__) + + +VALID_TAG_PATTERN = r'[\w][\w.-]{0,127}' +VALID_TAG_REGEX = re.compile(VALID_TAG_PATTERN) + + +def is_tag_name(reference): + match = VALID_TAG_REGEX.match(reference) + return match is not None and match.end() == len(reference) + + +@v2_bp.route('///manifests/', + methods=['GET']) +@process_jwt_auth +@require_repo_read +@anon_protect +def fetch_manifest_by_tagname(namespace, repo_name, tag_name): + logger.debug('Fetching tag manifest with name: %s', tag_name) + return make_response('Manifest {0}'.format(tag_name)) + + +@v2_bp.route('///manifests/', + methods=['PUT']) +@process_jwt_auth +@require_repo_write +@anon_protect +def write_manifest_by_tagname(namespace, repo_name, tag_name): + manifest_data = request.data + logger.debug('Manifest data: %s', manifest_data) + response = make_response('OK', 202) + response.headers['Docker-Content-Digest'] = digest_tools.sha256_digest(manifest_data) + response.headers['Location'] = 'https://fun.com' + return response + + +@v2_bp.route('///manifests/', + methods=['PUT']) +@process_jwt_auth +@require_repo_write +@anon_protect +def write_manifest(namespace, repo_name, tag_digest): + logger.debug('Writing tag manifest with name: %s', tag_digest) + + manifest_path = digest_tools.content_path(tag_digest) + storage.stream_write('local_us', manifest_path, get_input_stream(request)) + + return make_response('Manifest {0}'.format(tag_digest)) diff --git a/endpoints/v2/registry.py b/endpoints/v2/registry.py new file mode 100644 index 000000000..a70cc3a15 --- /dev/null +++ b/endpoints/v2/registry.py @@ -0,0 +1,12 @@ +import logging + +from endpoints.v2 import v2_bp + + +logging.getLogger(__name__) + + +@v2_bp.route() + +@process_auth +@anon_protect diff --git a/endpoints/v2/v2auth.py b/endpoints/v2/v2auth.py new file mode 100644 index 000000000..41c1143a7 --- /dev/null +++ b/endpoints/v2/v2auth.py @@ -0,0 +1,94 @@ +import logging +import re +import time +import jwt + +from flask import request, jsonify, abort + +from data import model +from auth.auth import process_auth +from auth.auth_context import get_authenticated_user +from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermission, + CreateRepositoryPermission) +from endpoints.v2 import v2_bp +from util.cache import no_cache +from util.names import parse_namespace_repository + + +logger = logging.getLogger(__name__) + + +SCOPE_REGEX = re.compile( + r'repository:([\.a-zA-Z0-9_\-]+/[\.a-zA-Z0-9_\-]+):(((push|pull|\*),)*(push|pull|\*))' +) + + +@v2_bp.route('/auth') +@process_auth +@no_cache +def generate_registry_jwt(): + audience_param = request.args.get('service') + logger.debug('Request audience: %s', audience_param) + + scope_param = request.args.get('scope') + logger.debug('Scope request: %s', scope_param) + + user = get_authenticated_user() + + access = [] + if scope_param is not None: + match = SCOPE_REGEX.match(scope_param) + if match is None or match.end() != len(scope_param): + logger.debug('Match: %s', match) + logger.debug('End: %s', match.end()) + logger.debug('len: %s', len(scope_param)) + logger.warning('Unable to decode repository and actions: %s', scope_param) + abort(400) + + logger.debug('Match: %s', match.groups()) + + namespace_and_repo = match.group(1) + actions = match.group(2).split(',') + + namespace, reponame = parse_namespace_repository(namespace_and_repo) + if 'pull' in actions and 'push' in actions: + repo = model.get_repository(namespace, reponame) + if repo: + if not ModifyRepositoryPermission(namespace, reponame): + abort(403) + else: + if not CreateRepositoryPermission(namespace): + abort(403) + logger.debug('Creating repository: %s/%s', namespace, reponame) + model.create_repository(namespace, reponame, user) + elif 'pull' in actions: + if not ReadRepositoryPermission(namespace, reponame): + abort(403) + + + access.append({ + 'type': 'repository', + 'name': namespace_and_repo, + 'actions': actions, + }) + + token_data = { + 'iss': 'token-issuer', + 'aud': audience_param, + 'nbf': int(time.time()), + 'exp': int(time.time() + 60), + 'sub': user.username, + 'access': access, + } + + with open('/Users/jake/Projects/registry-v2/ca/quay.host.crt') as cert_file: + certificate = ''.join(cert_file.readlines()[1:-1]).rstrip('\n') + + token_headers = { + 'x5c': [certificate], + } + + with open('/Users/jake/Projects/registry-v2/ca/quay.host.key.insecure') as private_key_file: + private_key = private_key_file.read() + + return jsonify({'token':jwt.encode(token_data, private_key, 'RS256', headers=token_headers)}) \ No newline at end of file diff --git a/registry.py b/registry.py index 4205fc635..1582a2879 100644 --- a/registry.py +++ b/registry.py @@ -6,10 +6,8 @@ from app import app as application # Note: We need to import this module to make sure the decorators are registered. import endpoints.decorated -from endpoints.index import index -from endpoints.tags import tags -from endpoints.registry import registry +from endpoints.v1 import v1_bp +from endpoints.v2 import v2_bp -application.register_blueprint(index, url_prefix='/v1') -application.register_blueprint(tags, url_prefix='/v1') -application.register_blueprint(registry, url_prefix='/v1') +application.register_blueprint(v1_bp, url_prefix='/v1') +application.register_blueprint(v2_bp, url_prefix='/v2') diff --git a/test/test_digest_tools.py b/test/test_digest_tools.py new file mode 100644 index 000000000..626eda86e --- /dev/null +++ b/test/test_digest_tools.py @@ -0,0 +1,48 @@ +import unittest + +from endpoints.v2.digest_tools import parse_digest, content_path, InvalidDigestException + +class TestParseDigest(unittest.TestCase): + def test_parse_good(self): + examples = [ + ('tarsum.v123123+sha1:123deadbeef', (True, 'v123123', 'sha1', '123deadbeef')), + ('tarsum.v1+sha256:123123', (True, 'v1', 'sha256', '123123')), + ('tarsum.v0+md5:abc', (True, 'v0', 'md5', 'abc')), + ('sha1:123deadbeef', (False, None, 'sha1', '123deadbeef')), + ('sha256:123123', (False, None, 'sha256', '123123')), + ('md5:abc', (False, None, 'md5', 'abc')), + ] + + for digest, output in examples: + self.assertEquals(parse_digest(digest), output) + + def test_parse_fail(self): + examples = [ + 'tarsum.v++sha1:123deadbeef', + '.v1+sha256:123123', + 'tarsum.v+md5:abc', + 'sha1:123deadbeefzxczxv', + 'sha256123123', + 'tarsum.v1+', + 'tarsum.v1123+sha1:', + ] + + for bad_digest in examples: + with self.assertRaises(InvalidDigestException): + parse_digest(bad_digest) + + +class TestDigestPath(unittest.TestCase): + def test_paths(self): + examples = [ + ('tarsum.v123123+sha1:123deadbeef', 'tarsum/v123123/sha1/12/123deadbeef'), + ('tarsum.v1+sha256:123123', 'tarsum/v1/sha256/12/123123'), + ('tarsum.v0+md5:abc', 'tarsum/v0/md5/ab/abc'), + ('sha1:123deadbeef', 'sha1/12/123deadbeef'), + ('sha256:123123', 'sha256/12/123123'), + ('md5:abc', 'md5/ab/abc'), + ('md5:1', 'md5/01/1'), + ] + + for digest, path in examples: + self.assertEquals(content_path(digest), path) diff --git a/workers/diffsworker.py b/workers/diffsworker.py index a02bd7301..b8b1379cd 100644 --- a/workers/diffsworker.py +++ b/workers/diffsworker.py @@ -2,7 +2,7 @@ import logging from app import image_diff_queue from data import model -from endpoints.registry import process_image_changes +from endpoints.v1.registry import process_image_changes from workers.worker import Worker