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

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