Start of a v2 API.

This commit is contained in:
Jake Moshenko 2015-06-22 17:37:13 -04:00
parent 3bfa2a6509
commit acbcc2e206
16 changed files with 508 additions and 55 deletions

13
app.py
View file

@ -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()

87
auth/jwt_auth.py Normal file
View file

@ -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

View file

@ -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'

29
endpoints/v1/__init__.py Normal file
View file

@ -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

View file

@ -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/<username>/', methods=['PUT'])
@v1_bp.route('/users/<username>/', methods=['PUT'])
@process_auth
@anon_allowed
def update_user(username):
@ -172,7 +172,7 @@ def update_user(username):
abort(403)
@index.route('/repositories/<path:repository>', methods=['PUT'])
@v1_bp.route('/repositories/<path:repository>', 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/<path:repository>/images', methods=['PUT'])
@v1_bp.route('/repositories/<path:repository>/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/<path:repository>/images', methods=['GET'])
@v1_bp.route('/repositories/<path:repository>/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/<path:repository>/images', methods=['DELETE'])
@v1_bp.route('/repositories/<path:repository>/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/<path:repository>/auth', methods=['PUT'])
@v1_bp.route('/repositories/<path:repository>/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

View file

@ -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/<image_id>/layer', methods=['HEAD'])
@v1_bp.route('/images/<image_id>/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/<image_id>/layer', methods=['GET'])
@v1_bp.route('/images/<image_id>/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/<image_id>/layer', methods=['PUT'])
@v1_bp.route('/images/<image_id>/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/<image_id>/checksum', methods=['PUT'])
@v1_bp.route('/images/<image_id>/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/<image_id>/json', methods=['GET'])
@v1_bp.route('/images/<image_id>/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/<image_id>/ancestry', methods=['GET'])
@v1_bp.route('/images/<image_id>/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/<image_id>/json', methods=['PUT'])
@v1_bp.route('/images/<image_id>/json', methods=['PUT'])
@process_auth
@extract_namespace_repo_from_session
@anon_protect

View file

@ -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/<path:repository>/tags',
@v1_bp.route('/repositories/<path:repository>/tags',
methods=['GET'])
@process_auth
@anon_protect
@ -34,7 +33,7 @@ def get_tags(namespace, repository):
abort(403)
@tags.route('/repositories/<path:repository>/tags/<tag>',
@v1_bp.route('/repositories/<path:repository>/tags/<tag>',
methods=['GET'])
@process_auth
@anon_protect
@ -51,7 +50,7 @@ def get_tag(namespace, repository, tag):
abort(403)
@tags.route('/repositories/<path:repository>/tags/<tag>',
@v1_bp.route('/repositories/<path:repository>/tags/<tag>',
methods=['PUT'])
@process_auth
@anon_protect
@ -74,7 +73,7 @@ def put_tag(namespace, repository, tag):
abort(403)
@tags.route('/repositories/<path:repository>/tags/<tag>',
@v1_bp.route('/repositories/<path:repository>/tags/<tag>',
methods=['DELETE'])
@process_auth
@anon_protect

62
endpoints/v2/__init__.py Normal file
View file

@ -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

20
endpoints/v2/blobs.py Normal file
View file

@ -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('/<namespace>/<repo_name>/blobs/<tarsum>', 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))

View file

@ -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())

63
endpoints/v2/manifest.py Normal file
View file

@ -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('/<namespace>/<repo_name>/manifests/<regex("' + VALID_TAG_PATTERN + '"):tag_name>',
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('/<namespace>/<repo_name>/manifests/<regex("' + VALID_TAG_PATTERN + '"):tag_name>',
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('/<namespace>/<repo_name>/manifests/<regex("' + digest_tools.DIGEST_PATTERN + '"):tag_digest>',
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))

12
endpoints/v2/registry.py Normal file
View file

@ -0,0 +1,12 @@
import logging
from endpoints.v2 import v2_bp
logging.getLogger(__name__)
@v2_bp.route()
@process_auth
@anon_protect

94
endpoints/v2/v2auth.py Normal file
View file

@ -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)})

View file

@ -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')

48
test/test_digest_tools.py Normal file
View file

@ -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)

View file

@ -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