Merge branch 'master' into create_data_interface_for_subsystem_api/tag_632
This commit is contained in:
commit
fdb63632b0
106 changed files with 2115 additions and 1992 deletions
|
@ -10,6 +10,7 @@ from endpoints.exception import NotFound
|
|||
from data import model
|
||||
|
||||
from digest import digest_tools
|
||||
from util.validation import VALID_LABEL_KEY_REGEX
|
||||
|
||||
BASE_MANIFEST_ROUTE = '/v1/repository/<apirepopath:repository>/manifest/<regex("{0}"):manifestref>'
|
||||
MANIFEST_DIGEST_ROUTE = BASE_MANIFEST_ROUTE.format(digest_tools.DIGEST_PATTERN)
|
||||
|
@ -92,9 +93,17 @@ class RepositoryManifestLabels(RepositoryParamResource):
|
|||
if label_validator.has_reserved_prefix(label_data['key']):
|
||||
abort(400, message='Label has a reserved prefix')
|
||||
|
||||
label = model.label.create_manifest_label(tag_manifest, label_data['key'],
|
||||
label_data['value'], 'api',
|
||||
media_type_name=label_data['media_type'])
|
||||
label = None
|
||||
try:
|
||||
label = model.label.create_manifest_label(tag_manifest, label_data['key'],
|
||||
label_data['value'], 'api',
|
||||
media_type_name=label_data['media_type'])
|
||||
except model.InvalidLabelKeyException:
|
||||
abort(400, message='Label is of an invalid format or missing please use %s format for labels'.format(
|
||||
VALID_LABEL_KEY_REGEX))
|
||||
except model.InvalidMediaTypeException:
|
||||
abort(400, message='Media type is invalid please use a valid media type of text/plain or application/json')
|
||||
|
||||
metadata = {
|
||||
'id': label.uuid,
|
||||
'key': label_data['key'],
|
||||
|
|
|
@ -161,10 +161,12 @@ class RepositoryNotification(RepositoryParamResource):
|
|||
@disallow_for_app_repositories
|
||||
def post(self, namespace, repository, uuid):
|
||||
""" Resets repository notification to 0 failures. """
|
||||
model.notification.reset_notification_number_of_failures(namespace, repository, uuid)
|
||||
log_action('reset_repo_notification', namespace,
|
||||
{'repo': repository, 'namespace': namespace, 'notification_id': uuid},
|
||||
repo=model.repository.get_repository(namespace, repository))
|
||||
reset = model.notification.reset_notification_number_of_failures(namespace, repository, uuid)
|
||||
if reset is not None:
|
||||
log_action('reset_repo_notification', namespace,
|
||||
{'repo': repository, 'namespace': namespace, 'notification_id': uuid,
|
||||
'event': reset.event.name, 'method': reset.method.name},
|
||||
repo=model.repository.get_repository(namespace, repository))
|
||||
|
||||
return 'No Content', 204
|
||||
|
||||
|
|
|
@ -1,58 +1,10 @@
|
|||
import datetime
|
||||
import json
|
||||
|
||||
from contextlib import contextmanager
|
||||
from data import model
|
||||
from endpoints.test.shared import conduct_call
|
||||
from endpoints.api import api
|
||||
|
||||
CSRF_TOKEN_KEY = '_csrf_token'
|
||||
CSRF_TOKEN = '123csrfforme'
|
||||
|
||||
|
||||
@contextmanager
|
||||
def client_with_identity(auth_username, client):
|
||||
with client.session_transaction() as sess:
|
||||
if auth_username and auth_username is not None:
|
||||
loaded = model.user.get_user(auth_username)
|
||||
sess['user_id'] = loaded.uuid
|
||||
sess['login_time'] = datetime.datetime.now()
|
||||
sess[CSRF_TOKEN_KEY] = CSRF_TOKEN
|
||||
else:
|
||||
sess['user_id'] = 'anonymous'
|
||||
|
||||
yield client
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['user_id'] = None
|
||||
sess['login_time'] = None
|
||||
sess[CSRF_TOKEN_KEY] = None
|
||||
|
||||
|
||||
def add_csrf_param(params):
|
||||
""" Returns a params dict with the CSRF parameter added. """
|
||||
params = params or {}
|
||||
params[CSRF_TOKEN_KEY] = CSRF_TOKEN
|
||||
return params
|
||||
|
||||
|
||||
def conduct_api_call(client, resource, method, params, body=None, expected_code=200):
|
||||
""" Conducts an API call to the given resource via the given client, and ensures its returned
|
||||
status matches the code given.
|
||||
|
||||
Returns the response.
|
||||
"""
|
||||
params = add_csrf_param(params)
|
||||
|
||||
final_url = api.url_for(resource, **params)
|
||||
|
||||
headers = {}
|
||||
headers.update({"Content-Type": "application/json"})
|
||||
|
||||
if body is not None:
|
||||
body = json.dumps(body)
|
||||
|
||||
rv = client.open(final_url, method=method, data=body, headers=headers)
|
||||
msg = '%s %s: got %s expected: %s | %s' % (method, final_url, rv.status_code, expected_code,
|
||||
rv.data)
|
||||
assert rv.status_code == expected_code, msg
|
||||
return rv
|
||||
return conduct_call(client, resource, api.url_for, method, params, body, expected_code)
|
||||
|
|
|
@ -16,7 +16,8 @@ from endpoints.api.trigger import (BuildTriggerList, BuildTrigger, BuildTriggerS
|
|||
BuildTriggerActivate, BuildTriggerAnalyze, ActivateBuildTrigger,
|
||||
TriggerBuildList, BuildTriggerFieldValues, BuildTriggerSources,
|
||||
BuildTriggerSourceNamespaces)
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from test.fixtures import *
|
||||
|
||||
BUILD_ARGS = {'build_uuid': '1234'}
|
||||
|
|
|
@ -2,8 +2,9 @@ import pytest
|
|||
|
||||
from data import model
|
||||
from endpoints.api import api
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.api.organization import Organization
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from test.fixtures import *
|
||||
|
||||
@pytest.mark.parametrize('expiration, expected_code', [
|
||||
|
|
|
@ -2,8 +2,9 @@ import pytest
|
|||
|
||||
from mock import patch, ANY, MagicMock
|
||||
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.api.repository import RepositoryTrust, Repository
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from features import FeatureNameValue
|
||||
|
||||
from test.fixtures import *
|
||||
|
@ -52,8 +53,8 @@ def test_signing_disabled(client):
|
|||
params = {'repository': 'devtable/simple'}
|
||||
response = conduct_api_call(cl, Repository, 'GET', params).json
|
||||
assert not response['trust_enabled']
|
||||
|
||||
|
||||
|
||||
|
||||
def test_sni_support():
|
||||
import ssl
|
||||
assert ssl.HAS_SNI
|
||||
|
|
|
@ -4,7 +4,8 @@ from playhouse.test_utils import assert_query_count
|
|||
|
||||
from data.model import _basequery
|
||||
from endpoints.api.search import ConductRepositorySearch, ConductSearch
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from test.fixtures import *
|
||||
|
||||
@pytest.mark.parametrize('query, expected_query_count', [
|
||||
|
|
|
@ -4,12 +4,13 @@ from flask_principal import AnonymousIdentity
|
|||
from endpoints.api import api
|
||||
from endpoints.api.repositorynotification import RepositoryNotification
|
||||
from endpoints.api.team import OrganizationTeamSyncing
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.api.repository import RepositoryTrust
|
||||
from endpoints.api.signing import RepositorySignatures
|
||||
from endpoints.api.search import ConductRepositorySearch
|
||||
from endpoints.api.superuser import SuperUserRepositoryBuildLogs, SuperUserRepositoryBuildResource
|
||||
from endpoints.api.superuser import SuperUserRepositoryBuildStatus
|
||||
from endpoints.test.shared import client_with_identity
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
|
|
|
@ -3,8 +3,9 @@ import pytest
|
|||
from collections import Counter
|
||||
from mock import patch
|
||||
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.api.signing import RepositorySignatures
|
||||
from endpoints.test.shared import client_with_identity
|
||||
|
||||
from test.fixtures import *
|
||||
|
||||
|
@ -14,21 +15,21 @@ VALID_TARGETS_MAP = {
|
|||
"latest": {
|
||||
"hashes": {
|
||||
"sha256": "2Q8GLEgX62VBWeL76axFuDj/Z1dd6Zhx0ZDM6kNwPkQ="
|
||||
},
|
||||
},
|
||||
"length": 2111
|
||||
}
|
||||
},
|
||||
},
|
||||
"expiration": "2020-05-22T10:26:46.618176424-04:00"
|
||||
},
|
||||
},
|
||||
"targets": {
|
||||
"targets": {
|
||||
"latest": {
|
||||
"hashes": {
|
||||
"sha256": "2Q8GLEgX62VBWeL76axFuDj/Z1dd6Zhx0ZDM6kNwPkQ="
|
||||
},
|
||||
},
|
||||
"length": 2111
|
||||
}
|
||||
},
|
||||
},
|
||||
"expiration": "2020-05-22T10:26:01.953414888-04:00"}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,9 +4,12 @@ import pytest
|
|||
|
||||
from mock import patch, Mock, MagicMock, call
|
||||
|
||||
|
||||
from endpoints.api.tag_models_interface import RepositoryTagHistory, Tag
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from endpoints.api.tag import RepositoryTag, RestoreTag, ListRepositoryTags
|
||||
|
||||
from features import FeatureNameValue
|
||||
|
||||
from test.fixtures import *
|
||||
|
|
|
@ -4,9 +4,11 @@ from mock import patch
|
|||
|
||||
from data import model
|
||||
from endpoints.api import api
|
||||
from endpoints.api.test.shared import client_with_identity, conduct_api_call
|
||||
from endpoints.api.test.shared import conduct_api_call
|
||||
from endpoints.api.team import OrganizationTeamSyncing, TeamMemberList
|
||||
from endpoints.api.organization import Organization
|
||||
from endpoints.test.shared import client_with_identity
|
||||
|
||||
from test.test_ldap import mock_ldap
|
||||
|
||||
from test.fixtures import *
|
||||
|
|
|
@ -5,7 +5,7 @@ from flask import url_for
|
|||
|
||||
from data import model
|
||||
from endpoints.appr.registry import appr_bp, blobs
|
||||
from endpoints.api.test.shared import client_with_identity
|
||||
from endpoints.test.shared import client_with_identity
|
||||
from test.fixtures import *
|
||||
|
||||
BLOB_ARGS = {'digest': 'abcd1235'}
|
||||
|
|
0
endpoints/test/__init__.py
Normal file
0
endpoints/test/__init__.py
Normal file
68
endpoints/test/shared.py
Normal file
68
endpoints/test/shared.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
import datetime
|
||||
import json
|
||||
import base64
|
||||
|
||||
from contextlib import contextmanager
|
||||
from data import model
|
||||
|
||||
from flask import g
|
||||
from flask_principal import Identity
|
||||
|
||||
CSRF_TOKEN_KEY = '_csrf_token'
|
||||
CSRF_TOKEN = '123csrfforme'
|
||||
|
||||
@contextmanager
|
||||
def client_with_identity(auth_username, client):
|
||||
with client.session_transaction() as sess:
|
||||
if auth_username and auth_username is not None:
|
||||
loaded = model.user.get_user(auth_username)
|
||||
sess['user_id'] = loaded.uuid
|
||||
sess['login_time'] = datetime.datetime.now()
|
||||
sess[CSRF_TOKEN_KEY] = CSRF_TOKEN
|
||||
else:
|
||||
sess['user_id'] = 'anonymous'
|
||||
|
||||
yield client
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
sess['user_id'] = None
|
||||
sess['login_time'] = None
|
||||
sess[CSRF_TOKEN_KEY] = None
|
||||
|
||||
|
||||
def add_csrf_param(params):
|
||||
""" Returns a params dict with the CSRF parameter added. """
|
||||
params = params or {}
|
||||
|
||||
if not CSRF_TOKEN_KEY in params:
|
||||
params[CSRF_TOKEN_KEY] = CSRF_TOKEN
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def gen_basic_auth(username, password):
|
||||
""" Generates a basic auth header. """
|
||||
return 'Basic ' + base64.b64encode("%s:%s" % (username, password))
|
||||
|
||||
|
||||
def conduct_call(client, resource, url_for, method, params, body=None, expected_code=200,
|
||||
headers=None):
|
||||
""" Conducts a call to a Flask endpoint. """
|
||||
params = add_csrf_param(params)
|
||||
|
||||
final_url = url_for(resource, **params)
|
||||
|
||||
headers = headers or {}
|
||||
headers.update({"Content-Type": "application/json"})
|
||||
|
||||
if body is not None:
|
||||
body = json.dumps(body)
|
||||
|
||||
# Required for anonymous calls to not exception.
|
||||
g.identity = Identity(None, 'none')
|
||||
|
||||
rv = client.open(final_url, method=method, data=body, headers=headers)
|
||||
msg = '%s %s: got %s expected: %s | %s' % (method, final_url, rv.status_code, expected_code,
|
||||
rv.data)
|
||||
assert rv.status_code == expected_code, msg
|
||||
return rv
|
|
@ -12,21 +12,19 @@ import features
|
|||
|
||||
from app import app, metric_queue, get_app_url, license_validator
|
||||
from auth.auth_context import get_grant_context
|
||||
from auth.permissions import (ReadRepositoryPermission, ModifyRepositoryPermission,
|
||||
AdministerRepositoryPermission)
|
||||
from auth.permissions import (
|
||||
ReadRepositoryPermission, ModifyRepositoryPermission, AdministerRepositoryPermission)
|
||||
from auth.registry_jwt_auth import process_registry_jwt_auth, get_auth_headers
|
||||
from data.interfaces.v2 import pre_oci_model as model
|
||||
from endpoints.decorators import anon_protect, anon_allowed
|
||||
from endpoints.v2.errors import V2RegistryException, Unauthorized, Unsupported, NameUnknown
|
||||
from endpoints.v2.models_pre_oci import data_model as model
|
||||
from util.http import abort
|
||||
from util.metrics.metricqueue import time_blueprint
|
||||
from util.registry.dockerver import docker_version
|
||||
from util.pagination import encrypt_page_token, decrypt_page_token
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
v2_bp = Blueprint('v2', __name__)
|
||||
license_validator.enforce_license_before_request(v2_bp)
|
||||
time_blueprint(v2_bp, metric_queue)
|
||||
|
@ -34,9 +32,7 @@ time_blueprint(v2_bp, metric_queue)
|
|||
|
||||
@v2_bp.app_errorhandler(V2RegistryException)
|
||||
def handle_registry_v2_exception(error):
|
||||
response = jsonify({
|
||||
'errors': [error.as_dict()]
|
||||
})
|
||||
response = jsonify({'errors': [error.as_dict()]})
|
||||
|
||||
response.status_code = error.http_status_code
|
||||
if response.status_code == 401:
|
||||
|
@ -53,6 +49,7 @@ def paginate(limit_kwarg_name='limit', offset_kwarg_name='offset',
|
|||
"""
|
||||
Decorates a handler adding a parsed pagination token and a callback to encode a response token.
|
||||
"""
|
||||
|
||||
def wrapper(func):
|
||||
@wraps(func)
|
||||
def wrapped(*args, **kwargs):
|
||||
|
@ -86,7 +83,9 @@ def paginate(limit_kwarg_name='limit', offset_kwarg_name='offset',
|
|||
kwargs[offset_kwarg_name] = offset
|
||||
kwargs[callback_kwarg_name] = callback
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
|
@ -94,17 +93,15 @@ def _require_repo_permission(permission_class, scopes=None, allow_public=False):
|
|||
def wrapper(func):
|
||||
@wraps(func)
|
||||
def wrapped(namespace_name, repo_name, *args, **kwargs):
|
||||
logger.debug('Checking permission %s for repo: %s/%s', permission_class,
|
||||
namespace_name, repo_name)
|
||||
logger.debug('Checking permission %s for repo: %s/%s', permission_class, namespace_name,
|
||||
repo_name)
|
||||
repository = namespace_name + '/' + repo_name
|
||||
repo = model.get_repository(namespace_name, repo_name)
|
||||
if repo is None:
|
||||
raise Unauthorized(repository=repository, scopes=scopes)
|
||||
|
||||
permission = permission_class(namespace_name, repo_name)
|
||||
if (permission.can() or
|
||||
(allow_public and
|
||||
repo.is_public)):
|
||||
if (permission.can() or (allow_public and repo.is_public)):
|
||||
if repo.kind != 'image':
|
||||
msg = 'This repository is for managing %s resources and not container images.' % repo.kind
|
||||
raise Unsupported(detail=msg)
|
||||
|
@ -112,16 +109,15 @@ def _require_repo_permission(permission_class, scopes=None, allow_public=False):
|
|||
raise Unauthorized(repository=repository, scopes=scopes)
|
||||
|
||||
return wrapped
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
require_repo_read = _require_repo_permission(ReadRepositoryPermission,
|
||||
scopes=['pull'],
|
||||
require_repo_read = _require_repo_permission(ReadRepositoryPermission, scopes=['pull'],
|
||||
allow_public=True)
|
||||
require_repo_write = _require_repo_permission(ModifyRepositoryPermission,
|
||||
scopes=['pull', 'push'])
|
||||
require_repo_admin = _require_repo_permission(AdministerRepositoryPermission,
|
||||
scopes=['pull', 'push'])
|
||||
require_repo_write = _require_repo_permission(ModifyRepositoryPermission, scopes=['pull', 'push'])
|
||||
require_repo_admin = _require_repo_permission(AdministerRepositoryPermission, scopes=[
|
||||
'pull', 'push'])
|
||||
|
||||
|
||||
def get_input_stream(flask_request):
|
||||
|
@ -138,7 +134,9 @@ def route_show_if(value):
|
|||
abort(404)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
|
@ -169,5 +167,4 @@ from endpoints.v2 import (
|
|||
catalog,
|
||||
manifest,
|
||||
tag,
|
||||
v2auth,
|
||||
)
|
||||
v2auth,)
|
||||
|
|
|
@ -10,22 +10,20 @@ import resumablehashlib
|
|||
from app import storage, app, get_app_url, metric_queue
|
||||
from auth.registry_jwt_auth import process_registry_jwt_auth
|
||||
from data import database
|
||||
from data.interfaces.v2 import pre_oci_model as model
|
||||
from digest import digest_tools
|
||||
from endpoints.common import parse_repository_name
|
||||
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write, get_input_stream
|
||||
from endpoints.v2.errors import (BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Unsupported,
|
||||
NameUnknown, LayerTooLarge)
|
||||
from endpoints.decorators import anon_protect
|
||||
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write, get_input_stream
|
||||
from endpoints.v2.errors import (
|
||||
BlobUnknown, BlobUploadInvalid, BlobUploadUnknown, Unsupported, NameUnknown, LayerTooLarge)
|
||||
from endpoints.v2.models_pre_oci import data_model as model
|
||||
from util.cache import cache_control
|
||||
from util.registry.filelike import wrap_with_handler, StreamSlice
|
||||
from util.registry.gzipstream import calculate_size_handler
|
||||
from util.registry.torrent import PieceHasher
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BASE_BLOB_ROUTE = '/<repopath:repository>/blobs/<regex("{0}"):digest>'
|
||||
BLOB_DIGEST_ROUTE = BASE_BLOB_ROUTE.format(digest_tools.DIGEST_PATTERN)
|
||||
RANGE_HEADER_REGEX = re.compile(r'^bytes=([0-9]+)-([0-9]+)$')
|
||||
|
@ -52,8 +50,7 @@ def check_blob_exists(namespace_name, repo_name, digest):
|
|||
headers = {
|
||||
'Docker-Content-Digest': digest,
|
||||
'Content-Length': blob.size,
|
||||
'Content-Type': BLOB_CONTENT_TYPE,
|
||||
}
|
||||
'Content-Type': BLOB_CONTENT_TYPE,}
|
||||
|
||||
# If our storage supports range requests, let the client know.
|
||||
if storage.get_supports_resumable_downloads(blob.locations):
|
||||
|
@ -102,10 +99,7 @@ def download_blob(namespace_name, repo_name, digest):
|
|||
storage.stream_read(blob.locations, path),
|
||||
headers=headers.update({
|
||||
'Content-Length': blob.size,
|
||||
'Content-Type': BLOB_CONTENT_TYPE,
|
||||
}),
|
||||
)
|
||||
|
||||
'Content-Type': BLOB_CONTENT_TYPE,}),)
|
||||
|
||||
|
||||
@v2_bp.route('/<repopath:repository>/blobs/uploads/', methods=['POST'])
|
||||
|
@ -128,13 +122,13 @@ def start_blob_upload(namespace_name, repo_name):
|
|||
return Response(
|
||||
status=202,
|
||||
headers={
|
||||
'Docker-Upload-UUID': new_upload_uuid,
|
||||
'Range': _render_range(0),
|
||||
'Location': get_app_url() + url_for('v2.upload_chunk',
|
||||
repository='%s/%s' % (namespace_name, repo_name),
|
||||
upload_uuid=new_upload_uuid)
|
||||
},
|
||||
)
|
||||
'Docker-Upload-UUID':
|
||||
new_upload_uuid,
|
||||
'Range':
|
||||
_render_range(0),
|
||||
'Location':
|
||||
get_app_url() + url_for('v2.upload_chunk', repository='%s/%s' %
|
||||
(namespace_name, repo_name), upload_uuid=new_upload_uuid)},)
|
||||
|
||||
# The user plans to send us the entire body right now.
|
||||
# Find the upload.
|
||||
|
@ -158,12 +152,11 @@ def start_blob_upload(namespace_name, repo_name):
|
|||
return Response(
|
||||
status=201,
|
||||
headers={
|
||||
'Docker-Content-Digest': digest,
|
||||
'Location': get_app_url() + url_for('v2.download_blob',
|
||||
repository='%s/%s' % (namespace_name, repo_name),
|
||||
digest=digest),
|
||||
},
|
||||
)
|
||||
'Docker-Content-Digest':
|
||||
digest,
|
||||
'Location':
|
||||
get_app_url() + url_for('v2.download_blob', repository='%s/%s' %
|
||||
(namespace_name, repo_name), digest=digest),},)
|
||||
|
||||
|
||||
@v2_bp.route('/<repopath:repository>/blobs/uploads/<upload_uuid>', methods=['GET'])
|
||||
|
@ -180,9 +173,8 @@ def fetch_existing_upload(namespace_name, repo_name, upload_uuid):
|
|||
status=204,
|
||||
headers={
|
||||
'Docker-Upload-UUID': upload_uuid,
|
||||
'Range': _render_range(blob_upload.byte_count+1), # byte ranges are exclusive
|
||||
},
|
||||
)
|
||||
'Range': _render_range(blob_upload.byte_count + 1), # byte ranges are exclusive
|
||||
},)
|
||||
|
||||
|
||||
@v2_bp.route('/<repopath:repository>/blobs/uploads/<upload_uuid>', methods=['PATCH'])
|
||||
|
@ -211,9 +203,7 @@ def upload_chunk(namespace_name, repo_name, upload_uuid):
|
|||
headers={
|
||||
'Location': _current_request_url(),
|
||||
'Range': _render_range(updated_blob_upload.byte_count, with_bytes_prefix=False),
|
||||
'Docker-Upload-UUID': upload_uuid,
|
||||
},
|
||||
)
|
||||
'Docker-Upload-UUID': upload_uuid,},)
|
||||
|
||||
|
||||
@v2_bp.route('/<repopath:repository>/blobs/uploads/<upload_uuid>', methods=['PUT'])
|
||||
|
@ -242,15 +232,12 @@ def monolithic_upload_or_last_chunk(namespace_name, repo_name, upload_uuid):
|
|||
_finish_upload(namespace_name, repo_name, updated_blob_upload, digest)
|
||||
|
||||
# Write the response to the client.
|
||||
return Response(
|
||||
status=201,
|
||||
headers={
|
||||
'Docker-Content-Digest': digest,
|
||||
'Location': get_app_url() + url_for('v2.download_blob',
|
||||
repository='%s/%s' % (namespace_name, repo_name),
|
||||
digest=digest),
|
||||
}
|
||||
)
|
||||
return Response(status=201, headers={
|
||||
'Docker-Content-Digest':
|
||||
digest,
|
||||
'Location':
|
||||
get_app_url() + url_for('v2.download_blob', repository='%s/%s' %
|
||||
(namespace_name, repo_name), digest=digest),})
|
||||
|
||||
|
||||
@v2_bp.route('/<repopath:repository>/blobs/uploads/<upload_uuid>', methods=['DELETE'])
|
||||
|
@ -300,9 +287,11 @@ def _abort_range_not_satisfiable(valid_end, upload_uuid):
|
|||
|
||||
TODO(jzelinskie): Unify this with the V2RegistryException class.
|
||||
"""
|
||||
flask_abort(Response(status=416, headers={'Location': _current_request_url(),
|
||||
'Range': '0-{0}'.format(valid_end),
|
||||
'Docker-Upload-UUID': upload_uuid}))
|
||||
flask_abort(
|
||||
Response(status=416, headers={
|
||||
'Location': _current_request_url(),
|
||||
'Range': '0-{0}'.format(valid_end),
|
||||
'Docker-Upload-UUID': upload_uuid}))
|
||||
|
||||
|
||||
def _parse_range_header(range_header_text):
|
||||
|
@ -415,16 +404,15 @@ def _upload_chunk(blob_upload, range_header):
|
|||
length,
|
||||
input_fp,
|
||||
blob_upload.storage_metadata,
|
||||
content_type=BLOB_CONTENT_TYPE,
|
||||
)
|
||||
content_type=BLOB_CONTENT_TYPE,)
|
||||
|
||||
if upload_error is not None:
|
||||
logger.error('storage.stream_upload_chunk returned error %s', upload_error)
|
||||
return None
|
||||
|
||||
# Update the chunk upload time metric.
|
||||
metric_queue.chunk_upload_time.Observe(time.time() - start_time,
|
||||
labelvalues=[length_written, list(location_set)[0]])
|
||||
metric_queue.chunk_upload_time.Observe(time.time() - start_time, labelvalues=[
|
||||
length_written, list(location_set)[0]])
|
||||
|
||||
# If we determined an uncompressed size and this is the first chunk, add it to the blob.
|
||||
# Otherwise, we clear the size from the blob as it was uploaded in multiple chunks.
|
||||
|
@ -499,8 +487,7 @@ def _finalize_blob_database(namespace_name, repo_name, blob_upload, digest, alre
|
|||
repo_name,
|
||||
digest,
|
||||
blob_upload,
|
||||
app.config['PUSH_TEMP_TAG_EXPIRATION_SEC'],
|
||||
)
|
||||
app.config['PUSH_TEMP_TAG_EXPIRATION_SEC'],)
|
||||
|
||||
# If it doesn't already exist, create the BitTorrent pieces for the blob.
|
||||
if blob_upload.piece_sha_state is not None and not already_existed:
|
||||
|
@ -521,5 +508,4 @@ def _finish_upload(namespace_name, repo_name, blob_upload, digest):
|
|||
repo_name,
|
||||
blob_upload,
|
||||
digest,
|
||||
_finalize_blob_storage(blob_upload, digest),
|
||||
)
|
||||
_finalize_blob_storage(blob_upload, digest),)
|
||||
|
|
|
@ -5,7 +5,8 @@ from flask import jsonify
|
|||
from auth.registry_jwt_auth import process_registry_jwt_auth, get_granted_entity
|
||||
from endpoints.decorators import anon_protect
|
||||
from endpoints.v2 import v2_bp, paginate
|
||||
from data.interfaces.v2 import pre_oci_model as model
|
||||
from endpoints.v2.models_pre_oci import data_model as model
|
||||
|
||||
|
||||
@v2_bp.route('/_catalog', methods=['GET'])
|
||||
@process_registry_jwt_auth()
|
||||
|
@ -18,12 +19,11 @@ def catalog_search(limit, offset, pagination_callback):
|
|||
username = entity.user.username
|
||||
|
||||
include_public = bool(features.PUBLIC_CATALOG)
|
||||
visible_repositories = model.get_visible_repositories(username, limit+1, offset,
|
||||
visible_repositories = model.get_visible_repositories(username, limit + 1, offset,
|
||||
include_public=include_public)
|
||||
response = jsonify({
|
||||
'repositories': ['%s/%s' % (repo.namespace_name, repo.name)
|
||||
for repo in visible_repositories][0:limit],
|
||||
})
|
||||
for repo in visible_repositories][0:limit],})
|
||||
|
||||
pagination_callback(len(visible_repositories), response)
|
||||
return response
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import bitmath
|
||||
|
||||
|
||||
class V2RegistryException(Exception):
|
||||
def __init__(self, error_code_str, message, detail, http_status_code=400,
|
||||
repository=None, scopes=None):
|
||||
def __init__(self, error_code_str, message, detail, http_status_code=400, repository=None,
|
||||
scopes=None):
|
||||
super(V2RegistryException, self).__init__(message)
|
||||
self.http_status_code = http_status_code
|
||||
self.repository = repository
|
||||
|
@ -15,104 +16,81 @@ class V2RegistryException(Exception):
|
|||
return {
|
||||
'code': self._error_code_str,
|
||||
'message': self.message,
|
||||
'detail': self._detail if self._detail is not None else {},
|
||||
}
|
||||
'detail': self._detail if self._detail is not None else {},}
|
||||
|
||||
|
||||
class BlobUnknown(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(BlobUnknown, self).__init__('BLOB_UNKNOWN',
|
||||
'blob unknown to registry',
|
||||
detail,
|
||||
404)
|
||||
super(BlobUnknown, self).__init__('BLOB_UNKNOWN', 'blob unknown to registry', detail, 404)
|
||||
|
||||
|
||||
class BlobUploadInvalid(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(BlobUploadInvalid, self).__init__('BLOB_UPLOAD_INVALID',
|
||||
'blob upload invalid',
|
||||
detail)
|
||||
super(BlobUploadInvalid, self).__init__('BLOB_UPLOAD_INVALID', 'blob upload invalid', detail)
|
||||
|
||||
|
||||
class BlobUploadUnknown(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(BlobUploadUnknown, self).__init__('BLOB_UPLOAD_UNKNOWN',
|
||||
'blob upload unknown to registry',
|
||||
detail,
|
||||
404)
|
||||
'blob upload unknown to registry', detail, 404)
|
||||
|
||||
|
||||
class DigestInvalid(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(DigestInvalid, self).__init__('DIGEST_INVALID',
|
||||
'provided digest did not match uploaded content',
|
||||
detail)
|
||||
'provided digest did not match uploaded content', detail)
|
||||
|
||||
|
||||
class ManifestBlobUnknown(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(ManifestBlobUnknown, self).__init__('MANIFEST_BLOB_UNKNOWN',
|
||||
'manifest blob unknown to registry',
|
||||
detail)
|
||||
'manifest blob unknown to registry', detail)
|
||||
|
||||
|
||||
class ManifestInvalid(V2RegistryException):
|
||||
def __init__(self, detail=None, http_status_code=400):
|
||||
super(ManifestInvalid, self).__init__('MANIFEST_INVALID',
|
||||
'manifest invalid',
|
||||
detail,
|
||||
super(ManifestInvalid, self).__init__('MANIFEST_INVALID', 'manifest invalid', detail,
|
||||
http_status_code)
|
||||
|
||||
|
||||
class ManifestUnknown(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(ManifestUnknown, self).__init__('MANIFEST_UNKNOWN',
|
||||
'manifest unknown',
|
||||
detail,
|
||||
404)
|
||||
super(ManifestUnknown, self).__init__('MANIFEST_UNKNOWN', 'manifest unknown', detail, 404)
|
||||
|
||||
|
||||
class ManifestUnverified(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(ManifestUnverified, self).__init__('MANIFEST_UNVERIFIED',
|
||||
'manifest failed signature verification',
|
||||
detail)
|
||||
'manifest failed signature verification', detail)
|
||||
|
||||
|
||||
class NameInvalid(V2RegistryException):
|
||||
def __init__(self, detail=None, message=None):
|
||||
super(NameInvalid, self).__init__('NAME_INVALID',
|
||||
message or 'invalid repository name',
|
||||
detail)
|
||||
super(NameInvalid, self).__init__('NAME_INVALID', message or 'invalid repository name', detail)
|
||||
|
||||
|
||||
class NameUnknown(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(NameUnknown, self).__init__('NAME_UNKNOWN',
|
||||
'repository name not known to registry',
|
||||
detail,
|
||||
404)
|
||||
super(NameUnknown, self).__init__('NAME_UNKNOWN', 'repository name not known to registry',
|
||||
detail, 404)
|
||||
|
||||
|
||||
class SizeInvalid(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(SizeInvalid, self).__init__('SIZE_INVALID',
|
||||
'provided length did not match content length',
|
||||
detail)
|
||||
'provided length did not match content length', detail)
|
||||
|
||||
|
||||
class TagAlreadyExists(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(TagAlreadyExists, self).__init__('TAG_ALREADY_EXISTS',
|
||||
'tag was already pushed',
|
||||
detail,
|
||||
super(TagAlreadyExists, self).__init__('TAG_ALREADY_EXISTS', 'tag was already pushed', detail,
|
||||
409)
|
||||
|
||||
|
||||
class TagInvalid(V2RegistryException):
|
||||
def __init__(self, detail=None):
|
||||
super(TagInvalid, self).__init__('TAG_INVALID',
|
||||
'manifest tag did not match URI',
|
||||
detail)
|
||||
super(TagInvalid, self).__init__('TAG_INVALID', 'manifest tag did not match URI', detail)
|
||||
|
||||
|
||||
class LayerTooLarge(V2RegistryException):
|
||||
def __init__(self, uploaded=None, max_allowed=None):
|
||||
|
@ -123,43 +101,33 @@ class LayerTooLarge(V2RegistryException):
|
|||
detail = {
|
||||
'reason': '%s is greater than maximum allowed size %s' % (uploaded, max_allowed),
|
||||
'max_allowed': max_allowed,
|
||||
'uploaded': uploaded,
|
||||
}
|
||||
'uploaded': uploaded,}
|
||||
|
||||
up_str = bitmath.Byte(uploaded).best_prefix().format("{value:.2f} {unit}")
|
||||
max_str = bitmath.Byte(max_allowed).best_prefix().format("{value:.2f} {unit}")
|
||||
message = 'Uploaded blob of %s is larger than %s allowed by this registry' % (up_str, max_str)
|
||||
message = 'Uploaded blob of %s is larger than %s allowed by this registry' % (up_str,
|
||||
max_str)
|
||||
|
||||
|
||||
class Unauthorized(V2RegistryException):
|
||||
def __init__(self, detail=None, repository=None, scopes=None):
|
||||
super(Unauthorized, self).__init__('UNAUTHORIZED',
|
||||
'access to the requested resource is not authorized',
|
||||
detail,
|
||||
401,
|
||||
repository=repository,
|
||||
scopes=scopes)
|
||||
super(Unauthorized,
|
||||
self).__init__('UNAUTHORIZED', 'access to the requested resource is not authorized',
|
||||
detail, 401, repository=repository, scopes=scopes)
|
||||
|
||||
|
||||
class Unsupported(V2RegistryException):
|
||||
def __init__(self, detail=None, message=None):
|
||||
super(Unsupported, self).__init__('UNSUPPORTED',
|
||||
message or 'The operation is unsupported.',
|
||||
detail,
|
||||
405)
|
||||
super(Unsupported, self).__init__('UNSUPPORTED', message or 'The operation is unsupported.',
|
||||
detail, 405)
|
||||
|
||||
|
||||
class InvalidLogin(V2RegistryException):
|
||||
def __init__(self, message=None):
|
||||
super(InvalidLogin, self).__init__('UNAUTHORIZED',
|
||||
message or 'Specified credentials are invalid',
|
||||
{},
|
||||
401)
|
||||
|
||||
super(InvalidLogin, self).__init__('UNAUTHORIZED', message or
|
||||
'Specified credentials are invalid', {}, 401)
|
||||
|
||||
|
||||
class InvalidRequest(V2RegistryException):
|
||||
def __init__(self, message=None):
|
||||
super(InvalidRequest, self).__init__('INVALID_REQUEST',
|
||||
message or 'Invalid request',
|
||||
{},
|
||||
400)
|
||||
super(InvalidRequest, self).__init__('INVALID_REQUEST', message or 'Invalid request', {}, 400)
|
||||
|
|
|
@ -8,14 +8,15 @@ import features
|
|||
|
||||
from app import docker_v2_signing_key, app, metric_queue
|
||||
from auth.registry_jwt_auth import process_registry_jwt_auth
|
||||
from data.interfaces.v2 import pre_oci_model as model, Label
|
||||
from digest import digest_tools
|
||||
from endpoints.common import parse_repository_name
|
||||
from endpoints.decorators import anon_protect
|
||||
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write
|
||||
from endpoints.v2.errors import (BlobUnknown, ManifestInvalid, ManifestUnknown, TagInvalid,
|
||||
NameInvalid)
|
||||
from endpoints.notificationhelper import spawn_notification
|
||||
from endpoints.v2 import v2_bp, require_repo_read, require_repo_write
|
||||
from endpoints.v2.errors import (
|
||||
BlobUnknown, ManifestInvalid, ManifestUnknown, TagInvalid, NameInvalid)
|
||||
from endpoints.v2.models_interface import Label
|
||||
from endpoints.v2.models_pre_oci import data_model as model
|
||||
from image.docker import ManifestException
|
||||
from image.docker.schema1 import DockerSchema1Manifest, DockerSchema1ManifestBuilder
|
||||
from image.docker.schema2 import DOCKER_SCHEMA2_CONTENT_TYPES
|
||||
|
@ -24,14 +25,13 @@ from util.names import VALID_TAG_PATTERN
|
|||
from util.registry.replication import queue_replication_batch
|
||||
from util.validation import is_json
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
BASE_MANIFEST_ROUTE = '/<repopath:repository>/manifests/<regex("{0}"):manifest_ref>'
|
||||
MANIFEST_DIGEST_ROUTE = BASE_MANIFEST_ROUTE.format(digest_tools.DIGEST_PATTERN)
|
||||
MANIFEST_TAGNAME_ROUTE = BASE_MANIFEST_ROUTE.format(VALID_TAG_PATTERN)
|
||||
|
||||
|
||||
@v2_bp.route(MANIFEST_TAGNAME_ROUTE, methods=['GET'])
|
||||
@parse_repository_name()
|
||||
@process_registry_jwt_auth(scopes=['pull'])
|
||||
|
@ -51,14 +51,14 @@ def fetch_manifest_by_tagname(namespace_name, repo_name, manifest_ref):
|
|||
repo = model.get_repository(namespace_name, repo_name)
|
||||
if repo is not None:
|
||||
track_and_log('pull_repo', repo, analytics_name='pull_repo_100x', analytics_sample=0.01,
|
||||
tag=manifest_ref)
|
||||
tag=manifest_ref)
|
||||
metric_queue.repository_pull.Inc(labelvalues=[namespace_name, repo_name, 'v2', True])
|
||||
|
||||
return Response(
|
||||
manifest.json,
|
||||
status=200,
|
||||
headers={'Content-Type': manifest.media_type, 'Docker-Content-Digest': manifest.digest},
|
||||
)
|
||||
headers={'Content-Type': manifest.media_type,
|
||||
'Docker-Content-Digest': manifest.digest},)
|
||||
|
||||
|
||||
@v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['GET'])
|
||||
|
@ -77,8 +77,9 @@ def fetch_manifest_by_digest(namespace_name, repo_name, manifest_ref):
|
|||
track_and_log('pull_repo', repo, manifest_digest=manifest_ref)
|
||||
metric_queue.repository_pull.Inc(labelvalues=[namespace_name, repo_name, 'v2', True])
|
||||
|
||||
return Response(manifest.json, status=200, headers={'Content-Type': manifest.media_type,
|
||||
'Docker-Content-Digest': manifest.digest})
|
||||
return Response(manifest.json, status=200, headers={
|
||||
'Content-Type': manifest.media_type,
|
||||
'Docker-Content-Digest': manifest.digest})
|
||||
|
||||
|
||||
def _reject_manifest2_schema2(func):
|
||||
|
@ -88,6 +89,7 @@ def _reject_manifest2_schema2(func):
|
|||
raise ManifestInvalid(detail={'message': 'manifest schema version not supported'},
|
||||
http_status_code=415)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
|
@ -130,8 +132,7 @@ def write_manifest_by_digest(namespace_name, repo_name, manifest_ref):
|
|||
|
||||
|
||||
def _write_manifest(namespace_name, repo_name, manifest):
|
||||
if (manifest.namespace == '' and
|
||||
features.LIBRARY_SUPPORT and
|
||||
if (manifest.namespace == '' and features.LIBRARY_SUPPORT and
|
||||
namespace_name == app.config['LIBRARY_NAMESPACE']):
|
||||
pass
|
||||
elif manifest.namespace != namespace_name:
|
||||
|
@ -173,8 +174,7 @@ def _write_manifest(namespace_name, repo_name, manifest):
|
|||
rewritten_image.comment,
|
||||
rewritten_image.command,
|
||||
rewritten_image.compat_json,
|
||||
rewritten_image.parent_image_id,
|
||||
)
|
||||
rewritten_image.parent_image_id,)
|
||||
except ManifestException as me:
|
||||
logger.exception("exception when rewriting v1 metadata")
|
||||
raise ManifestInvalid(detail={'message': 'failed synthesizing v1 metadata: %s' % me.message})
|
||||
|
@ -211,12 +211,11 @@ def _write_manifest_and_log(namespace_name, repo_name, manifest):
|
|||
'OK',
|
||||
status=202,
|
||||
headers={
|
||||
'Docker-Content-Digest': manifest.digest,
|
||||
'Location': url_for('v2.fetch_manifest_by_digest',
|
||||
repository='%s/%s' % (namespace_name, repo_name),
|
||||
manifest_ref=manifest.digest),
|
||||
},
|
||||
)
|
||||
'Docker-Content-Digest':
|
||||
manifest.digest,
|
||||
'Location':
|
||||
url_for('v2.fetch_manifest_by_digest', repository='%s/%s' % (namespace_name, repo_name),
|
||||
manifest_ref=manifest.digest),},)
|
||||
|
||||
|
||||
@v2_bp.route(MANIFEST_DIGEST_ROUTE, methods=['DELETE'])
|
||||
|
@ -270,5 +269,6 @@ def _generate_and_store_manifest(namespace_name, repo_name, tag_name):
|
|||
manifest.bytes)
|
||||
return manifest
|
||||
|
||||
|
||||
def _determine_media_type(value):
|
||||
media_type_name = 'application/json' if is_json(value) else 'text/plain'
|
||||
|
|
258
endpoints/v2/models_interface.py
Normal file
258
endpoints/v2/models_interface.py
Normal file
|
@ -0,0 +1,258 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
from collections import namedtuple
|
||||
|
||||
from namedlist import namedlist
|
||||
from six import add_metaclass
|
||||
|
||||
|
||||
class Repository(
|
||||
namedtuple('Repository', [
|
||||
'id', 'name', 'namespace_name', 'description', 'is_public', 'kind', 'trust_enabled'])):
|
||||
"""
|
||||
Repository represents a namespaced collection of tags.
|
||||
:type id: int
|
||||
:type name: string
|
||||
:type namespace_name: string
|
||||
:type description: string
|
||||
:type is_public: bool
|
||||
:type kind: string
|
||||
:type trust_enabled: bool
|
||||
"""
|
||||
|
||||
|
||||
class ManifestJSON(namedtuple('ManifestJSON', ['digest', 'json', 'media_type'])):
|
||||
"""
|
||||
ManifestJSON represents a Manifest of any format.
|
||||
"""
|
||||
|
||||
|
||||
class Tag(namedtuple('Tag', ['name', 'repository'])):
|
||||
"""
|
||||
Tag represents a user-facing alias for referencing a set of Manifests.
|
||||
"""
|
||||
|
||||
|
||||
class BlobUpload(
|
||||
namedlist('BlobUpload', [
|
||||
'uuid', 'byte_count', 'uncompressed_byte_count', 'chunk_count', 'sha_state', 'location_name',
|
||||
'storage_metadata', 'piece_sha_state', 'piece_hashes', 'repo_namespace_name', 'repo_name'])):
|
||||
"""
|
||||
BlobUpload represents the current state of an Blob being uploaded.
|
||||
"""
|
||||
|
||||
|
||||
class Blob(namedtuple('Blob', ['uuid', 'digest', 'size', 'locations'])):
|
||||
"""
|
||||
Blob represents an opaque binary blob saved to the storage system.
|
||||
"""
|
||||
|
||||
|
||||
class RepositoryReference(namedtuple('RepositoryReference', ['id', 'name', 'namespace_name'])):
|
||||
"""
|
||||
RepositoryReference represents a reference to a Repository, without its full metadata.
|
||||
"""
|
||||
|
||||
|
||||
class Label(namedtuple('Label', ['key', 'value', 'source_type', 'media_type'])):
|
||||
"""
|
||||
Label represents a key-value pair that describes a particular Manifest.
|
||||
"""
|
||||
|
||||
|
||||
@add_metaclass(ABCMeta)
|
||||
class DockerRegistryV2DataInterface(object):
|
||||
"""
|
||||
Interface that represents all data store interactions required by a Docker Registry v1.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_repository(self, namespace_name, repo_name, creating_user=None):
|
||||
"""
|
||||
Creates a new repository under the specified namespace with the given name. The user supplied is
|
||||
the user creating the repository, if any.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_repository(self, namespace_name, repo_name):
|
||||
"""
|
||||
Returns a repository tuple for the repository with the given name under the given namespace.
|
||||
Returns None if no such repository was found.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def has_active_tag(self, namespace_name, repo_name, tag_name):
|
||||
"""
|
||||
Returns whether there is an active tag for the tag with the given name under the matching
|
||||
repository, if any, or none if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_manifest_by_tag(self, namespace_name, repo_name, tag_name):
|
||||
"""
|
||||
Returns the current manifest for the tag with the given name under the matching repository, if
|
||||
any, or None if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_manifest_by_digest(self, namespace_name, repo_name, digest):
|
||||
"""
|
||||
Returns the manifest matching the given digest under the matching repository, if any, or None if
|
||||
none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_manifest_by_digest(self, namespace_name, repo_name, digest):
|
||||
"""
|
||||
Deletes the manifest with the associated digest (if any) and returns all removed tags that
|
||||
pointed to that manifest. If the manifest was not found, returns an empty list.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_docker_v1_metadata_by_tag(self, namespace_name, repo_name, tag_name):
|
||||
"""
|
||||
Returns the Docker V1 metadata associated with the tag with the given name under the matching
|
||||
repository, if any. If none, returns None.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_docker_v1_metadata_by_image_id(self, namespace_name, repo_name, docker_image_ids):
|
||||
"""
|
||||
Returns a map of Docker V1 metadata for each given image ID, matched under the repository with
|
||||
the given namespace and name. Returns an empty map if the matching repository was not found.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_parents_docker_v1_metadata(self, namespace_name, repo_name, docker_image_id):
|
||||
"""
|
||||
Returns an ordered list containing the Docker V1 metadata for each parent of the image with the
|
||||
given docker ID under the matching repository. Returns an empty list if the image was not found.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_manifest_and_update_tag(self, namespace_name, repo_name, tag_name, manifest_digest,
|
||||
manifest_bytes):
|
||||
"""
|
||||
Creates a new manifest with the given digest and byte data, and assigns the tag with the given
|
||||
name under the matching repository to it.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def synthesize_v1_image(self, repository, storage, image_id, created, comment, command,
|
||||
compat_json, parent_image_id):
|
||||
"""
|
||||
Synthesizes a V1 image under the specified repository, pointing to the given storage and returns
|
||||
the V1 metadata for the synthesized image.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_manifest(self, namespace_name, repo_name, tag_name, leaf_layer_docker_id,
|
||||
manifest_digest, manifest_bytes):
|
||||
"""
|
||||
Saves a manifest pointing to the given leaf image, with the given manifest, under the matching
|
||||
repository as a tag with the given name.
|
||||
|
||||
Returns a boolean whether or not the tag was newly created or not.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def repository_tags(self, namespace_name, repo_name, limit, offset):
|
||||
"""
|
||||
Returns the active tags under the repository with the given name and namespace.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_visible_repositories(self, username, limit, offset):
|
||||
"""
|
||||
Returns the repositories visible to the user with the given username, if any.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_blob_upload(self, namespace_name, repo_name, upload_uuid, location_name,
|
||||
storage_metadata):
|
||||
"""
|
||||
Creates a blob upload under the matching repository with the given UUID and metadata.
|
||||
Returns whether the matching repository exists.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def blob_upload_by_uuid(self, namespace_name, repo_name, upload_uuid):
|
||||
"""
|
||||
Searches for a blob upload with the given UUID under the given repository and returns it or None
|
||||
if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_blob_upload(self, blob_upload):
|
||||
"""
|
||||
Saves any changes to the blob upload object given to the backing data store.
|
||||
Fields that can change:
|
||||
- uncompressed_byte_count
|
||||
- piece_hashes
|
||||
- piece_sha_state
|
||||
- storage_metadata
|
||||
- byte_count
|
||||
- chunk_count
|
||||
- sha_state
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_blob_upload(self, namespace_name, repo_name, uuid):
|
||||
"""
|
||||
Deletes the blob upload with the given uuid under the matching repository. If none, does
|
||||
nothing.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_blob_and_temp_tag(self, namespace_name, repo_name, blob_digest, blob_upload,
|
||||
expiration_sec):
|
||||
"""
|
||||
Creates a blob and links a temporary tag with the specified expiration to it under the matching
|
||||
repository.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_blob_by_digest(self, namespace_name, repo_name, digest):
|
||||
"""
|
||||
Returns the blob with the given digest under the matching repository or None if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_bittorrent_pieces(self, blob, piece_size, piece_bytes):
|
||||
"""
|
||||
Saves the BitTorrent piece hashes for the given blob.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create_manifest_labels(self, namespace_name, repo_name, manifest_digest, labels):
|
||||
"""
|
||||
Creates a new labels for the provided manifest.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_blob_path(self, blob):
|
||||
"""
|
||||
Once everything is moved over, this could be in util.registry and not even touch the database.
|
||||
"""
|
||||
pass
|
287
endpoints/v2/models_pre_oci.py
Normal file
287
endpoints/v2/models_pre_oci.py
Normal file
|
@ -0,0 +1,287 @@
|
|||
from peewee import IntegrityError
|
||||
|
||||
from data import model, database
|
||||
from data.model import DataModelException
|
||||
from endpoints.v2.models_interface import (
|
||||
Blob,
|
||||
BlobUpload,
|
||||
DockerRegistryV2DataInterface,
|
||||
ManifestJSON,
|
||||
Repository,
|
||||
RepositoryReference,
|
||||
Tag,)
|
||||
from image.docker.v1 import DockerV1Metadata
|
||||
|
||||
_MEDIA_TYPE = "application/vnd.docker.distribution.manifest.v1+prettyjws"
|
||||
|
||||
|
||||
class PreOCIModel(DockerRegistryV2DataInterface):
|
||||
"""
|
||||
PreOCIModel implements the data model for the v2 Docker Registry protocol using a database schema
|
||||
before it was changed to support the OCI specification.
|
||||
"""
|
||||
|
||||
def create_repository(self, namespace_name, repo_name, creating_user=None):
|
||||
return model.repository.create_repository(namespace_name, repo_name, creating_user)
|
||||
|
||||
def get_repository(self, namespace_name, repo_name):
|
||||
repo = model.repository.get_repository(namespace_name, repo_name)
|
||||
if repo is None:
|
||||
return None
|
||||
return _repository_for_repo(repo)
|
||||
|
||||
def has_active_tag(self, namespace_name, repo_name, tag_name):
|
||||
try:
|
||||
model.tag.get_active_tag(namespace_name, repo_name, tag_name)
|
||||
return True
|
||||
except database.RepositoryTag.DoesNotExist:
|
||||
return False
|
||||
|
||||
def get_manifest_by_tag(self, namespace_name, repo_name, tag_name):
|
||||
try:
|
||||
manifest = model.tag.load_tag_manifest(namespace_name, repo_name, tag_name)
|
||||
return ManifestJSON(digest=manifest.digest, json=manifest.json_data, media_type=_MEDIA_TYPE)
|
||||
except model.InvalidManifestException:
|
||||
return None
|
||||
|
||||
def get_manifest_by_digest(self, namespace_name, repo_name, digest):
|
||||
try:
|
||||
manifest = model.tag.load_manifest_by_digest(namespace_name, repo_name, digest)
|
||||
return ManifestJSON(digest=digest, json=manifest.json_data, media_type=_MEDIA_TYPE)
|
||||
except model.InvalidManifestException:
|
||||
return None
|
||||
|
||||
def delete_manifest_by_digest(self, namespace_name, repo_name, digest):
|
||||
def _tag_view(tag):
|
||||
return Tag(name=tag.name, repository=RepositoryReference(
|
||||
id=tag.repository_id,
|
||||
name=repo_name,
|
||||
namespace_name=namespace_name,))
|
||||
|
||||
tags = model.tag.delete_manifest_by_digest(namespace_name, repo_name, digest)
|
||||
return [_tag_view(tag) for tag in tags]
|
||||
|
||||
def get_docker_v1_metadata_by_tag(self, namespace_name, repo_name, tag_name):
|
||||
try:
|
||||
repo_img = model.tag.get_tag_image(namespace_name, repo_name, tag_name, include_storage=True)
|
||||
return _docker_v1_metadata(namespace_name, repo_name, repo_img)
|
||||
except DataModelException:
|
||||
return None
|
||||
|
||||
def get_docker_v1_metadata_by_image_id(self, namespace_name, repo_name, docker_image_ids):
|
||||
repo = model.repository.get_repository(namespace_name, repo_name)
|
||||
if repo is None:
|
||||
return {}
|
||||
|
||||
images_query = model.image.lookup_repository_images(repo, docker_image_ids)
|
||||
return {
|
||||
image.docker_image_id: _docker_v1_metadata(namespace_name, repo_name, image)
|
||||
for image in images_query}
|
||||
|
||||
def get_parents_docker_v1_metadata(self, namespace_name, repo_name, docker_image_id):
|
||||
repo_image = model.image.get_repo_image(namespace_name, repo_name, docker_image_id)
|
||||
if repo_image is None:
|
||||
return []
|
||||
|
||||
parents = model.image.get_parent_images(namespace_name, repo_name, repo_image)
|
||||
return [_docker_v1_metadata(namespace_name, repo_name, image) for image in parents]
|
||||
|
||||
def create_manifest_and_update_tag(self, namespace_name, repo_name, tag_name, manifest_digest,
|
||||
manifest_bytes):
|
||||
try:
|
||||
model.tag.associate_generated_tag_manifest(namespace_name, repo_name, tag_name,
|
||||
manifest_digest, manifest_bytes)
|
||||
except IntegrityError:
|
||||
# It's already there!
|
||||
pass
|
||||
|
||||
def synthesize_v1_image(self, repository, storage, image_id, created, comment, command,
|
||||
compat_json, parent_image_id):
|
||||
repo = model.repository.get_repository(repository.namespace_name, repository.name)
|
||||
if repo is None:
|
||||
raise DataModelException('Unknown repository: %s/%s' % (repository.namespace_name,
|
||||
repository.name))
|
||||
|
||||
parent_image = None
|
||||
if parent_image_id is not None:
|
||||
parent_image = model.image.get_image(repo, parent_image_id)
|
||||
if parent_image is None:
|
||||
raise DataModelException('Unknown parent image: %s' % parent_image_id)
|
||||
|
||||
storage_obj = model.storage.get_storage_by_uuid(storage.uuid)
|
||||
if storage_obj is None:
|
||||
raise DataModelException('Unknown storage: %s' % storage.uuid)
|
||||
|
||||
repo_image = model.image.synthesize_v1_image(repo, storage_obj, image_id, created, comment,
|
||||
command, compat_json, parent_image)
|
||||
return _docker_v1_metadata(repo.namespace_user.username, repo.name, repo_image)
|
||||
|
||||
def save_manifest(self, namespace_name, repo_name, tag_name, leaf_layer_docker_id,
|
||||
manifest_digest, manifest_bytes):
|
||||
(_, newly_created) = model.tag.store_tag_manifest(
|
||||
namespace_name, repo_name, tag_name, leaf_layer_docker_id, manifest_digest, manifest_bytes)
|
||||
return newly_created
|
||||
|
||||
def repository_tags(self, namespace_name, repo_name, limit, offset):
|
||||
def _tag_view(tag):
|
||||
return Tag(name=tag.name, repository=RepositoryReference(
|
||||
id=tag.repository_id,
|
||||
name=repo_name,
|
||||
namespace_name=namespace_name,))
|
||||
|
||||
tags_query = model.tag.list_repository_tags(namespace_name, repo_name)
|
||||
tags_query = tags_query.limit(limit).offset(offset)
|
||||
return [_tag_view(tag) for tag in tags_query]
|
||||
|
||||
def get_visible_repositories(self, username, limit, offset, include_public=None):
|
||||
if include_public is None:
|
||||
include_public = (username is None)
|
||||
|
||||
query = model.repository.get_visible_repositories(username, kind_filter='image',
|
||||
include_public=include_public)
|
||||
query = query.limit(limit).offset(offset)
|
||||
return [_repository_for_repo(repo) for repo in query]
|
||||
|
||||
def create_blob_upload(self, namespace_name, repo_name, upload_uuid, location_name,
|
||||
storage_metadata):
|
||||
try:
|
||||
model.blob.initiate_upload(namespace_name, repo_name, upload_uuid, location_name,
|
||||
storage_metadata)
|
||||
return True
|
||||
except database.Repository.DoesNotExist:
|
||||
return False
|
||||
|
||||
def blob_upload_by_uuid(self, namespace_name, repo_name, upload_uuid):
|
||||
try:
|
||||
found = model.blob.get_blob_upload(namespace_name, repo_name, upload_uuid)
|
||||
except model.InvalidBlobUpload:
|
||||
return None
|
||||
|
||||
return BlobUpload(
|
||||
repo_namespace_name=namespace_name,
|
||||
repo_name=repo_name,
|
||||
uuid=upload_uuid,
|
||||
byte_count=found.byte_count,
|
||||
uncompressed_byte_count=found.uncompressed_byte_count,
|
||||
chunk_count=found.chunk_count,
|
||||
sha_state=found.sha_state,
|
||||
piece_sha_state=found.piece_sha_state,
|
||||
piece_hashes=found.piece_hashes,
|
||||
location_name=found.location.name,
|
||||
storage_metadata=found.storage_metadata,)
|
||||
|
||||
def update_blob_upload(self, blob_upload):
|
||||
# Lookup the blob upload object.
|
||||
try:
|
||||
blob_upload_record = model.blob.get_blob_upload(blob_upload.repo_namespace_name,
|
||||
blob_upload.repo_name, blob_upload.uuid)
|
||||
except model.InvalidBlobUpload:
|
||||
return
|
||||
|
||||
blob_upload_record.uncompressed_byte_count = blob_upload.uncompressed_byte_count
|
||||
blob_upload_record.piece_hashes = blob_upload.piece_hashes
|
||||
blob_upload_record.piece_sha_state = blob_upload.piece_sha_state
|
||||
blob_upload_record.storage_metadata = blob_upload.storage_metadata
|
||||
blob_upload_record.byte_count = blob_upload.byte_count
|
||||
blob_upload_record.chunk_count = blob_upload.chunk_count
|
||||
blob_upload_record.sha_state = blob_upload.sha_state
|
||||
blob_upload_record.save()
|
||||
|
||||
def delete_blob_upload(self, namespace_name, repo_name, uuid):
|
||||
try:
|
||||
found = model.blob.get_blob_upload(namespace_name, repo_name, uuid)
|
||||
found.delete_instance()
|
||||
except model.InvalidBlobUpload:
|
||||
return
|
||||
|
||||
def create_blob_and_temp_tag(self, namespace_name, repo_name, blob_digest, blob_upload,
|
||||
expiration_sec):
|
||||
location_obj = model.storage.get_image_location_for_name(blob_upload.location_name)
|
||||
blob_record = model.blob.store_blob_record_and_temp_link(
|
||||
namespace_name, repo_name, blob_digest, location_obj.id, blob_upload.byte_count,
|
||||
expiration_sec, blob_upload.uncompressed_byte_count)
|
||||
return Blob(
|
||||
uuid=blob_record.uuid,
|
||||
digest=blob_digest,
|
||||
size=blob_upload.byte_count,
|
||||
locations=[blob_upload.location_name],)
|
||||
|
||||
def lookup_blobs_by_digest(self, namespace_name, repo_name, digests):
|
||||
def _blob_view(blob_record):
|
||||
return Blob(
|
||||
uuid=blob_record.uuid,
|
||||
digest=blob_record.content_checksum,
|
||||
size=blob_record.image_size,
|
||||
locations=None, # Note: Locations is None in this case.
|
||||
)
|
||||
|
||||
repo = model.repository.get_repository(namespace_name, repo_name)
|
||||
if repo is None:
|
||||
return {}
|
||||
query = model.storage.lookup_repo_storages_by_content_checksum(repo, digests)
|
||||
return {storage.content_checksum: _blob_view(storage) for storage in query}
|
||||
|
||||
def get_blob_by_digest(self, namespace_name, repo_name, digest):
|
||||
try:
|
||||
blob_record = model.blob.get_repo_blob_by_digest(namespace_name, repo_name, digest)
|
||||
return Blob(
|
||||
uuid=blob_record.uuid,
|
||||
digest=digest,
|
||||
size=blob_record.image_size,
|
||||
locations=blob_record.locations,)
|
||||
except model.BlobDoesNotExist:
|
||||
return None
|
||||
|
||||
def save_bittorrent_pieces(self, blob, piece_size, piece_bytes):
|
||||
blob_record = model.storage.get_storage_by_uuid(blob.uuid)
|
||||
model.storage.save_torrent_info(blob_record, piece_size, piece_bytes)
|
||||
|
||||
def create_manifest_labels(self, namespace_name, repo_name, manifest_digest, labels):
|
||||
if not labels:
|
||||
# No point in doing anything more.
|
||||
return
|
||||
|
||||
tag_manifest = model.tag.load_manifest_by_digest(namespace_name, repo_name, manifest_digest)
|
||||
for label in labels:
|
||||
model.label.create_manifest_label(tag_manifest, label.key, label.value, label.source_type,
|
||||
label.media_type)
|
||||
|
||||
def get_blob_path(self, blob):
|
||||
blob_record = model.storage.get_storage_by_uuid(blob.uuid)
|
||||
return model.storage.get_layer_path(blob_record)
|
||||
|
||||
|
||||
def _docker_v1_metadata(namespace_name, repo_name, repo_image):
|
||||
"""
|
||||
Returns a DockerV1Metadata object for the given Pre-OCI repo_image under the
|
||||
repository with the given namespace and name. Note that the namespace and
|
||||
name are passed here as an optimization, and are *not checked* against the
|
||||
image.
|
||||
"""
|
||||
return DockerV1Metadata(
|
||||
namespace_name=namespace_name,
|
||||
repo_name=repo_name,
|
||||
image_id=repo_image.docker_image_id,
|
||||
checksum=repo_image.v1_checksum,
|
||||
content_checksum=repo_image.storage.content_checksum,
|
||||
compat_json=repo_image.v1_json_metadata,
|
||||
created=repo_image.created,
|
||||
comment=repo_image.comment,
|
||||
command=repo_image.command,
|
||||
# TODO: make sure this isn't needed anywhere, as it is expensive to lookup
|
||||
parent_image_id=None,)
|
||||
|
||||
|
||||
def _repository_for_repo(repo):
|
||||
""" Returns a Repository object representing the Pre-OCI data model repo instance given. """
|
||||
return Repository(
|
||||
id=repo.id,
|
||||
name=repo.name,
|
||||
namespace_name=repo.namespace_user.username,
|
||||
description=repo.description,
|
||||
is_public=model.repository.is_repository_public(repo),
|
||||
kind=model.repository.get_repo_kind_name(repo),
|
||||
trust_enabled=repo.trust_enabled,)
|
||||
|
||||
|
||||
data_model = PreOCIModel()
|
|
@ -2,9 +2,10 @@ from flask import jsonify
|
|||
|
||||
from auth.registry_jwt_auth import process_registry_jwt_auth
|
||||
from endpoints.common import parse_repository_name
|
||||
from endpoints.v2 import v2_bp, require_repo_read, paginate
|
||||
from endpoints.decorators import anon_protect
|
||||
from data.interfaces.v2 import pre_oci_model as model
|
||||
from endpoints.v2 import v2_bp, require_repo_read, paginate
|
||||
from endpoints.v2.models_pre_oci import data_model as model
|
||||
|
||||
|
||||
@v2_bp.route('/<repopath:repository>/tags/list', methods=['GET'])
|
||||
@parse_repository_name()
|
||||
|
@ -16,8 +17,7 @@ def list_all_tags(namespace_name, repo_name, limit, offset, pagination_callback)
|
|||
tags = model.repository_tags(namespace_name, repo_name, limit, offset)
|
||||
response = jsonify({
|
||||
'name': '{0}/{1}'.format(namespace_name, repo_name),
|
||||
'tags': [tag.name for tag in tags],
|
||||
})
|
||||
'tags': [tag.name for tag in tags],})
|
||||
|
||||
pagination_callback(len(tags), response)
|
||||
return response
|
||||
|
|
|
@ -13,18 +13,18 @@ from auth.permissions import (ModifyRepositoryPermission, ReadRepositoryPermissi
|
|||
from endpoints.decorators import anon_protect
|
||||
from endpoints.v2 import v2_bp
|
||||
from endpoints.v2.errors import InvalidLogin, NameInvalid, InvalidRequest, Unsupported, Unauthorized
|
||||
from data.interfaces.v2 import pre_oci_model as model
|
||||
from endpoints.v2.models_pre_oci import data_model as model
|
||||
from util.cache import no_cache
|
||||
from util.names import parse_namespace_repository, REPOSITORY_NAME_REGEX
|
||||
from util.security.registry_jwt import (generate_bearer_token, build_context_and_subject, QUAY_TUF_ROOT,
|
||||
SIGNER_TUF_ROOT, DISABLED_TUF_ROOT)
|
||||
from util.security.registry_jwt import (generate_bearer_token, build_context_and_subject,
|
||||
QUAY_TUF_ROOT, SIGNER_TUF_ROOT, DISABLED_TUF_ROOT)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
|
||||
SCOPE_REGEX_TEMPLATE = r'^repository:((?:{}\/)?((?:[\.a-zA-Z0-9_\-]+\/)*[\.a-zA-Z0-9_\-]+)):((?:push|pull|\*)(?:,(?:push|pull|\*))*)$'
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_scope_regex():
|
||||
hostname = re.escape(app.config['SERVER_HOSTNAME'])
|
||||
|
@ -64,8 +64,7 @@ def generate_registry_jwt(auth_result):
|
|||
|
||||
access = []
|
||||
user_event_data = {
|
||||
'action': 'login',
|
||||
}
|
||||
'action': 'login',}
|
||||
tuf_root = DISABLED_TUF_ROOT
|
||||
|
||||
if len(scope_param) > 0:
|
||||
|
@ -101,8 +100,8 @@ def generate_registry_jwt(auth_result):
|
|||
repo_is_public = repo is not None and repo.is_public
|
||||
invalid_repo_message = ''
|
||||
if repo is not None and repo.kind != 'image':
|
||||
invalid_repo_message = (('This repository is for managing %s resources ' +
|
||||
'and not container images.') % repo.kind)
|
||||
invalid_repo_message = ((
|
||||
'This repository is for managing %s resources ' + 'and not container images.') % repo.kind)
|
||||
|
||||
if 'push' in actions:
|
||||
# If there is no valid user or token, then the repository cannot be
|
||||
|
@ -150,8 +149,7 @@ def generate_registry_jwt(auth_result):
|
|||
access.append({
|
||||
'type': 'repository',
|
||||
'name': registry_and_repo,
|
||||
'actions': final_actions,
|
||||
})
|
||||
'actions': final_actions,})
|
||||
|
||||
# Set the user event data for the auth.
|
||||
if 'push' in final_actions:
|
||||
|
@ -164,8 +162,7 @@ def generate_registry_jwt(auth_result):
|
|||
user_event_data = {
|
||||
'action': user_action,
|
||||
'repository': reponame,
|
||||
'namespace': namespace,
|
||||
}
|
||||
'namespace': namespace,}
|
||||
tuf_root = get_tuf_root(repo, namespace, reponame)
|
||||
|
||||
elif user is None and token is None:
|
||||
|
@ -179,7 +176,8 @@ def generate_registry_jwt(auth_result):
|
|||
event.publish_event_data('docker-cli', user_event_data)
|
||||
|
||||
# Build the signed JWT.
|
||||
context, subject = build_context_and_subject(user=user, token=token, oauthtoken=oauthtoken, tuf_root=tuf_root)
|
||||
context, subject = build_context_and_subject(user=user, token=token, oauthtoken=oauthtoken,
|
||||
tuf_root=tuf_root)
|
||||
token = generate_bearer_token(audience_param, subject, context, access,
|
||||
TOKEN_VALIDITY_LIFETIME_S, instance_keys)
|
||||
return jsonify({'token': token})
|
||||
|
@ -188,7 +186,7 @@ def generate_registry_jwt(auth_result):
|
|||
def get_tuf_root(repo, namespace, reponame):
|
||||
if not features.SIGNING or repo is None or not repo.trust_enabled:
|
||||
return DISABLED_TUF_ROOT
|
||||
|
||||
|
||||
# Users with write access to a repo will see signer-rooted TUF metadata
|
||||
if ModifyRepositoryPermission(namespace, reponame).can():
|
||||
return SIGNER_TUF_ROOT
|
||||
|
|
|
@ -10,9 +10,9 @@ from auth.auth_context import get_authenticated_user
|
|||
from auth.decorators import process_auth
|
||||
from auth.permissions import ReadRepositoryPermission
|
||||
from data import database
|
||||
from data.interfaces.verbs import pre_oci_model as model
|
||||
from endpoints.common import route_show_if, parse_repository_name
|
||||
from endpoints.decorators import anon_protect
|
||||
from endpoints.verbs.models_pre_oci import pre_oci_model as model
|
||||
from endpoints.v2.blob import BLOB_DIGEST_ROUTE
|
||||
from image.appc import AppCImageFormatter
|
||||
from image.docker.squashed import SquashedDockerImageFormatter
|
||||
|
@ -22,16 +22,14 @@ from util.http import exact_abort
|
|||
from util.registry.filelike import wrap_with_handler
|
||||
from util.registry.queuefile import QueueFile
|
||||
from util.registry.queueprocess import QueueProcess
|
||||
from util.registry.torrent import (make_torrent, per_user_torrent_filename, public_torrent_filename,
|
||||
PieceHasher)
|
||||
|
||||
from util.registry.torrent import (
|
||||
make_torrent, per_user_torrent_filename, public_torrent_filename, PieceHasher)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
verbs = Blueprint('verbs', __name__)
|
||||
license_validator.enforce_license_before_request(verbs)
|
||||
|
||||
|
||||
LAYER_MIMETYPE = 'binary/octet-stream'
|
||||
|
||||
|
||||
|
@ -60,7 +58,8 @@ def _open_stream(formatter, repo_image, tag, derived_image_id, handlers):
|
|||
logger.debug('Returning image layer %s: %s', current_image.image_id, current_image_path)
|
||||
yield current_image_stream
|
||||
|
||||
stream = formatter.build_stream(repo_image, tag, derived_image_id, get_next_image, get_next_layer)
|
||||
stream = formatter.build_stream(repo_image, tag, derived_image_id, get_next_image,
|
||||
get_next_layer)
|
||||
|
||||
for handler_fn in handlers:
|
||||
stream = wrap_with_handler(stream, handler_fn)
|
||||
|
@ -89,6 +88,7 @@ def _write_derived_image_to_storage(verb, derived_image, queue_file):
|
|||
""" Read from the generated stream and write it back to the storage engine. This method runs in a
|
||||
separate process.
|
||||
"""
|
||||
|
||||
def handle_exception(ex):
|
||||
logger.debug('Exception when building %s derived image %s: %s', verb, derived_image.ref, ex)
|
||||
|
||||
|
@ -139,8 +139,9 @@ def _torrent_for_blob(blob, is_public):
|
|||
torrent_file = make_torrent(name, webseed, blob.size, torrent_info.piece_length,
|
||||
torrent_info.pieces)
|
||||
|
||||
headers = {'Content-Type': 'application/x-bittorrent',
|
||||
'Content-Disposition': 'attachment; filename={0}.torrent'.format(name)}
|
||||
headers = {
|
||||
'Content-Type': 'application/x-bittorrent',
|
||||
'Content-Disposition': 'attachment; filename={0}.torrent'.format(name)}
|
||||
|
||||
return make_response(torrent_file, 200, headers)
|
||||
|
||||
|
@ -158,8 +159,7 @@ def _torrent_repo_verb(repo_image, tag, verb, **kwargs):
|
|||
abort(406)
|
||||
|
||||
# Return the torrent.
|
||||
repo = model.get_repository(repo_image.repository.namespace_name,
|
||||
repo_image.repository.name)
|
||||
repo = model.get_repository(repo_image.repository.namespace_name, repo_image.repository.name)
|
||||
repo_is_public = repo is not None and repo.is_public
|
||||
torrent = _torrent_for_blob(derived_image.blob, repo_is_public)
|
||||
|
||||
|
@ -229,15 +229,14 @@ def _repo_verb(namespace, repository, tag, verb, formatter, sign=False, checker=
|
|||
metric_queue.repository_pull.Inc(labelvalues=[namespace, repository, verb, True])
|
||||
|
||||
# Lookup/create the derived image for the verb and repo image.
|
||||
derived_image = model.lookup_or_create_derived_image(repo_image, verb,
|
||||
storage.preferred_locations[0],
|
||||
varying_metadata={'tag': tag})
|
||||
derived_image = model.lookup_or_create_derived_image(
|
||||
repo_image, verb, storage.preferred_locations[0], varying_metadata={'tag': tag})
|
||||
if not derived_image.blob.uploading:
|
||||
logger.debug('Derived %s image %s exists in storage', verb, derived_image.ref)
|
||||
derived_layer_path = model.get_blob_path(derived_image.blob)
|
||||
is_head_request = request.method == 'HEAD'
|
||||
download_url = storage.get_direct_download_url(derived_image.blob.locations, derived_layer_path,
|
||||
head=is_head_request)
|
||||
download_url = storage.get_direct_download_url(derived_image.blob.locations,
|
||||
derived_layer_path, head=is_head_request)
|
||||
if download_url:
|
||||
logger.debug('Redirecting to download URL for derived %s image %s', verb, derived_image.ref)
|
||||
return redirect(download_url)
|
||||
|
@ -246,8 +245,9 @@ def _repo_verb(namespace, repository, tag, verb, formatter, sign=False, checker=
|
|||
database.close_db_filter(None)
|
||||
|
||||
logger.debug('Sending cached derived %s image %s', verb, derived_image.ref)
|
||||
return send_file(storage.stream_read_file(derived_image.blob.locations, derived_layer_path),
|
||||
mimetype=LAYER_MIMETYPE)
|
||||
return send_file(
|
||||
storage.stream_read_file(derived_image.blob.locations, derived_layer_path),
|
||||
mimetype=LAYER_MIMETYPE)
|
||||
|
||||
logger.debug('Building and returning derived %s image %s', verb, derived_image.ref)
|
||||
|
||||
|
@ -270,9 +270,12 @@ def _repo_verb(namespace, repository, tag, verb, formatter, sign=False, checker=
|
|||
# and send the results to the client and storage.
|
||||
handlers = [hasher.update]
|
||||
args = (formatter, repo_image, tag, derived_image_id, handlers)
|
||||
queue_process = QueueProcess(_open_stream,
|
||||
8 * 1024, 10 * 1024 * 1024, # 8K/10M chunk/max
|
||||
args, finished=_store_metadata_and_cleanup)
|
||||
queue_process = QueueProcess(
|
||||
_open_stream,
|
||||
8 * 1024,
|
||||
10 * 1024 * 1024, # 8K/10M chunk/max
|
||||
args,
|
||||
finished=_store_metadata_and_cleanup)
|
||||
|
||||
client_queue_file = QueueFile(queue_process.create_queue(), 'client')
|
||||
storage_queue_file = QueueFile(queue_process.create_queue(), 'storage')
|
||||
|
@ -336,11 +339,13 @@ def get_aci_signature(server, namespace, repository, tag, os, arch):
|
|||
|
||||
@route_show_if(features.ACI_CONVERSION)
|
||||
@anon_protect
|
||||
@verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci/<os>/<arch>/', methods=['GET', 'HEAD'])
|
||||
@verbs.route('/aci/<server>/<namespace>/<repository>/<tag>/aci/<os>/<arch>/', methods=[
|
||||
'GET', 'HEAD'])
|
||||
@process_auth
|
||||
def get_aci_image(server, namespace, repository, tag, os, arch):
|
||||
return _repo_verb(namespace, repository, tag, 'aci', AppCImageFormatter(),
|
||||
sign=True, checker=os_arch_checker(os, arch), os=os, arch=arch)
|
||||
return _repo_verb(namespace, repository, tag, 'aci',
|
||||
AppCImageFormatter(), sign=True, checker=os_arch_checker(os, arch), os=os,
|
||||
arch=arch)
|
||||
|
||||
|
||||
@anon_protect
|
||||
|
|
154
endpoints/verbs/models_interface.py
Normal file
154
endpoints/verbs/models_interface.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
from collections import namedtuple
|
||||
|
||||
from six import add_metaclass
|
||||
|
||||
|
||||
class Repository(
|
||||
namedtuple('Repository', ['id', 'name', 'namespace_name', 'description', 'is_public',
|
||||
'kind'])):
|
||||
"""
|
||||
Repository represents a namespaced collection of tags.
|
||||
:type id: int
|
||||
:type name: string
|
||||
:type namespace_name: string
|
||||
:type description: string
|
||||
:type is_public: bool
|
||||
:type kind: string
|
||||
"""
|
||||
|
||||
|
||||
class DerivedImage(namedtuple('DerivedImage', ['ref', 'blob', 'internal_source_image_db_id'])):
|
||||
"""
|
||||
DerivedImage represents a user-facing alias for an image which was derived from another image.
|
||||
"""
|
||||
|
||||
|
||||
class RepositoryReference(namedtuple('RepositoryReference', ['id', 'name', 'namespace_name'])):
|
||||
"""
|
||||
RepositoryReference represents a reference to a Repository, without its full metadata.
|
||||
"""
|
||||
|
||||
|
||||
class ImageWithBlob(
|
||||
namedtuple('Image', [
|
||||
'image_id', 'blob', 'compat_metadata', 'repository', 'internal_db_id', 'v1_metadata'])):
|
||||
"""
|
||||
ImageWithBlob represents a user-facing alias for referencing an image, along with its blob.
|
||||
"""
|
||||
|
||||
|
||||
class Blob(namedtuple('Blob', ['uuid', 'size', 'uncompressed_size', 'uploading', 'locations'])):
|
||||
"""
|
||||
Blob represents an opaque binary blob saved to the storage system.
|
||||
"""
|
||||
|
||||
|
||||
class TorrentInfo(namedtuple('TorrentInfo', ['piece_length', 'pieces'])):
|
||||
"""
|
||||
TorrentInfo represents the torrent piece information associated with a blob.
|
||||
"""
|
||||
|
||||
|
||||
@add_metaclass(ABCMeta)
|
||||
class VerbsDataInterface(object):
|
||||
"""
|
||||
Interface that represents all data store interactions required by the registry's custom HTTP
|
||||
verbs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_repository(self, namespace_name, repo_name):
|
||||
"""
|
||||
Returns a repository tuple for the repository with the given name under the given namespace.
|
||||
Returns None if no such repository was found.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_manifest_layers_with_blobs(self, repo_image):
|
||||
"""
|
||||
Returns the full set of manifest layers and their associated blobs starting at the given
|
||||
repository image and working upwards to the root image.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_blob_path(self, blob):
|
||||
"""
|
||||
Returns the storage path for the given blob.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_derived_image_signature(self, derived_image, signer_name):
|
||||
"""
|
||||
Returns the signature associated with the derived image and a specific signer or None if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_derived_image_signature(self, derived_image, signer_name, signature):
|
||||
"""
|
||||
Sets the calculated signature for the given derived image and signer to that specified.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_derived_image(self, derived_image):
|
||||
"""
|
||||
Deletes a derived image and all of its storage.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_blob_size(self, blob, size):
|
||||
"""
|
||||
Sets the size field on a blob to the value specified.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_repo_blob_by_digest(self, namespace_name, repo_name, digest):
|
||||
"""
|
||||
Returns the blob with the given digest under the matching repository or None if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_torrent_info(self, blob):
|
||||
"""
|
||||
Returns the torrent information associated with the given blob or None if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_torrent_info(self, blob, piece_length, pieces):
|
||||
"""
|
||||
Sets the torrent infomation associated with the given blob to that specified.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def lookup_derived_image(self, repo_image, verb, varying_metadata=None):
|
||||
"""
|
||||
Looks up the derived image for the given repository image, verb and optional varying metadata
|
||||
and returns it or None if none.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def lookup_or_create_derived_image(self, repo_image, verb, location, varying_metadata=None):
|
||||
"""
|
||||
Looks up the derived image for the given repository image, verb and optional varying metadata
|
||||
and returns it. If none exists, a new derived image is created.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_tag_image(self, namespace_name, repo_name, tag_name):
|
||||
"""
|
||||
Returns the image associated with the live tag with the given name under the matching repository
|
||||
or None if none.
|
||||
"""
|
||||
pass
|
203
endpoints/verbs/models_pre_oci.py
Normal file
203
endpoints/verbs/models_pre_oci.py
Normal file
|
@ -0,0 +1,203 @@
|
|||
import json
|
||||
|
||||
from data import model
|
||||
from image.docker.v1 import DockerV1Metadata
|
||||
|
||||
from endpoints.verbs.models_interface import (
|
||||
Blob,
|
||||
DerivedImage,
|
||||
ImageWithBlob,
|
||||
Repository,
|
||||
RepositoryReference,
|
||||
TorrentInfo,
|
||||
VerbsDataInterface,)
|
||||
|
||||
|
||||
class PreOCIModel(VerbsDataInterface):
|
||||
"""
|
||||
PreOCIModel implements the data model for the registry's custom HTTP verbs using a database schema
|
||||
before it was changed to support the OCI specification.
|
||||
"""
|
||||
|
||||
def get_repository(self, namespace_name, repo_name):
|
||||
repo = model.repository.get_repository(namespace_name, repo_name)
|
||||
if repo is None:
|
||||
return None
|
||||
|
||||
return _repository_for_repo(repo)
|
||||
|
||||
def get_manifest_layers_with_blobs(self, repo_image):
|
||||
repo_image_record = model.image.get_image_by_id(
|
||||
repo_image.repository.namespace_name, repo_image.repository.name, repo_image.image_id)
|
||||
|
||||
parents = model.image.get_parent_images_with_placements(
|
||||
repo_image.repository.namespace_name, repo_image.repository.name, repo_image_record)
|
||||
|
||||
yield repo_image
|
||||
|
||||
for parent in parents:
|
||||
metadata = {}
|
||||
try:
|
||||
metadata = json.loads(parent.v1_json_metadata)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
yield ImageWithBlob(
|
||||
image_id=parent.docker_image_id,
|
||||
blob=_blob(parent.storage),
|
||||
repository=repo_image.repository,
|
||||
compat_metadata=metadata,
|
||||
v1_metadata=_docker_v1_metadata(repo_image.repository.namespace_name,
|
||||
repo_image.repository.name, parent),
|
||||
internal_db_id=parent.id,)
|
||||
|
||||
def get_derived_image_signature(self, derived_image, signer_name):
|
||||
storage = model.storage.get_storage_by_uuid(derived_image.blob.uuid)
|
||||
signature_entry = model.storage.lookup_storage_signature(storage, signer_name)
|
||||
if signature_entry is None:
|
||||
return None
|
||||
|
||||
return signature_entry.signature
|
||||
|
||||
def set_derived_image_signature(self, derived_image, signer_name, signature):
|
||||
storage = model.storage.get_storage_by_uuid(derived_image.blob.uuid)
|
||||
signature_entry = model.storage.find_or_create_storage_signature(storage, signer_name)
|
||||
signature_entry.signature = signature
|
||||
signature_entry.uploading = False
|
||||
signature_entry.save()
|
||||
|
||||
def delete_derived_image(self, derived_image):
|
||||
model.image.delete_derived_storage_by_uuid(derived_image.blob.uuid)
|
||||
|
||||
def set_blob_size(self, blob, size):
|
||||
storage_entry = model.storage.get_storage_by_uuid(blob.uuid)
|
||||
storage_entry.image_size = size
|
||||
storage_entry.uploading = False
|
||||
storage_entry.save()
|
||||
|
||||
def get_blob_path(self, blob):
|
||||
blob_record = model.storage.get_storage_by_uuid(blob.uuid)
|
||||
return model.storage.get_layer_path(blob_record)
|
||||
|
||||
def get_repo_blob_by_digest(self, namespace_name, repo_name, digest):
|
||||
try:
|
||||
blob_record = model.blob.get_repo_blob_by_digest(namespace_name, repo_name, digest)
|
||||
except model.BlobDoesNotExist:
|
||||
return None
|
||||
|
||||
return _blob(blob_record)
|
||||
|
||||
def get_torrent_info(self, blob):
|
||||
blob_record = model.storage.get_storage_by_uuid(blob.uuid)
|
||||
|
||||
try:
|
||||
torrent_info = model.storage.get_torrent_info(blob_record)
|
||||
except model.TorrentInfoDoesNotExist:
|
||||
return None
|
||||
|
||||
return TorrentInfo(
|
||||
pieces=torrent_info.pieces,
|
||||
piece_length=torrent_info.piece_length,)
|
||||
|
||||
def set_torrent_info(self, blob, piece_length, pieces):
|
||||
blob_record = model.storage.get_storage_by_uuid(blob.uuid)
|
||||
model.storage.save_torrent_info(blob_record, piece_length, pieces)
|
||||
|
||||
def lookup_derived_image(self, repo_image, verb, varying_metadata=None):
|
||||
blob_record = model.image.find_derived_storage_for_image(repo_image.internal_db_id, verb,
|
||||
varying_metadata)
|
||||
if blob_record is None:
|
||||
return None
|
||||
|
||||
return _derived_image(blob_record, repo_image)
|
||||
|
||||
def lookup_or_create_derived_image(self, repo_image, verb, location, varying_metadata=None):
|
||||
blob_record = model.image.find_or_create_derived_storage(repo_image.internal_db_id, verb,
|
||||
location, varying_metadata)
|
||||
return _derived_image(blob_record, repo_image)
|
||||
|
||||
def get_tag_image(self, namespace_name, repo_name, tag_name):
|
||||
try:
|
||||
found = model.tag.get_tag_image(namespace_name, repo_name, tag_name, include_storage=True)
|
||||
except model.DataModelException:
|
||||
return None
|
||||
|
||||
metadata = {}
|
||||
try:
|
||||
metadata = json.loads(found.v1_json_metadata)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return ImageWithBlob(
|
||||
image_id=found.docker_image_id,
|
||||
blob=_blob(found.storage),
|
||||
repository=RepositoryReference(
|
||||
namespace_name=namespace_name,
|
||||
name=repo_name,
|
||||
id=found.repository_id,),
|
||||
compat_metadata=metadata,
|
||||
v1_metadata=_docker_v1_metadata(namespace_name, repo_name, found),
|
||||
internal_db_id=found.id,)
|
||||
|
||||
|
||||
pre_oci_model = PreOCIModel()
|
||||
|
||||
|
||||
def _docker_v1_metadata(namespace_name, repo_name, repo_image):
|
||||
"""
|
||||
Returns a DockerV1Metadata object for the given Pre-OCI repo_image under the
|
||||
repository with the given namespace and name. Note that the namespace and
|
||||
name are passed here as an optimization, and are *not checked* against the
|
||||
image.
|
||||
"""
|
||||
return DockerV1Metadata(
|
||||
namespace_name=namespace_name,
|
||||
repo_name=repo_name,
|
||||
image_id=repo_image.docker_image_id,
|
||||
checksum=repo_image.v1_checksum,
|
||||
compat_json=repo_image.v1_json_metadata,
|
||||
created=repo_image.created,
|
||||
comment=repo_image.comment,
|
||||
command=repo_image.command,
|
||||
|
||||
# Note: These are not needed in verbs and are expensive to load, so we just skip them.
|
||||
content_checksum=None,
|
||||
parent_image_id=None,)
|
||||
|
||||
|
||||
def _derived_image(blob_record, repo_image):
|
||||
"""
|
||||
Returns a DerivedImage object for the given Pre-OCI data model blob and repo_image instance.
|
||||
"""
|
||||
return DerivedImage(
|
||||
ref=repo_image.internal_db_id,
|
||||
blob=_blob(blob_record),
|
||||
internal_source_image_db_id=repo_image.internal_db_id,)
|
||||
|
||||
|
||||
def _blob(blob_record):
|
||||
"""
|
||||
Returns a Blob object for the given Pre-OCI data model blob instance.
|
||||
"""
|
||||
if hasattr(blob_record, 'locations'):
|
||||
locations = blob_record.locations
|
||||
else:
|
||||
locations = model.storage.get_storage_locations(blob_record.uuid)
|
||||
|
||||
return Blob(
|
||||
uuid=blob_record.uuid,
|
||||
size=blob_record.image_size,
|
||||
uncompressed_size=blob_record.uncompressed_size,
|
||||
uploading=blob_record.uploading,
|
||||
locations=locations,)
|
||||
|
||||
|
||||
def _repository_for_repo(repo):
|
||||
""" Returns a Repository object representing the Pre-OCI data model repo instance given. """
|
||||
return Repository(
|
||||
id=repo.id,
|
||||
name=repo.name,
|
||||
namespace_name=repo.namespace_user.username,
|
||||
description=repo.description,
|
||||
is_public=model.repository.is_repository_public(repo),
|
||||
kind=model.repository.get_repo_kind_name(repo),)
|
74
endpoints/verbs/test/test_security.py
Normal file
74
endpoints/verbs/test/test_security.py
Normal file
|
@ -0,0 +1,74 @@
|
|||
import pytest
|
||||
|
||||
from flask import url_for
|
||||
from endpoints.test.shared import conduct_call, gen_basic_auth
|
||||
from test.fixtures import *
|
||||
|
||||
NO_ACCESS_USER = 'freshuser'
|
||||
READ_ACCESS_USER = 'reader'
|
||||
ADMIN_ACCESS_USER = 'devtable'
|
||||
CREATOR_ACCESS_USER = 'creator'
|
||||
|
||||
PUBLIC_REPO = 'public/publicrepo'
|
||||
PRIVATE_REPO = 'devtable/shared'
|
||||
ORG_REPO = 'buynlarge/orgrepo'
|
||||
ANOTHER_ORG_REPO = 'buynlarge/anotherorgrepo'
|
||||
|
||||
ACI_ARGS = {
|
||||
'server': 'someserver',
|
||||
'tag': 'fake',
|
||||
'os': 'linux',
|
||||
'arch': 'x64',}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('user', [
|
||||
(0, None),
|
||||
(1, NO_ACCESS_USER),
|
||||
(2, READ_ACCESS_USER),
|
||||
(3, CREATOR_ACCESS_USER),
|
||||
(4, ADMIN_ACCESS_USER),])
|
||||
@pytest.mark.parametrize(
|
||||
'endpoint,method,repository,single_repo_path,params,expected_statuses',
|
||||
[
|
||||
('get_aci_signature', 'GET', PUBLIC_REPO, False, ACI_ARGS, (404, 404, 404, 404, 404)),
|
||||
('get_aci_signature', 'GET', PRIVATE_REPO, False, ACI_ARGS, (403, 403, 404, 403, 404)),
|
||||
('get_aci_signature', 'GET', ORG_REPO, False, ACI_ARGS, (403, 403, 404, 403, 404)),
|
||||
('get_aci_signature', 'GET', ANOTHER_ORG_REPO, False, ACI_ARGS, (403, 403, 403, 403, 404)),
|
||||
|
||||
# get_aci_image
|
||||
('get_aci_image', 'GET', PUBLIC_REPO, False, ACI_ARGS, (404, 404, 404, 404, 404)),
|
||||
('get_aci_image', 'GET', PRIVATE_REPO, False, ACI_ARGS, (403, 403, 404, 403, 404)),
|
||||
('get_aci_image', 'GET', ORG_REPO, False, ACI_ARGS, (403, 403, 404, 403, 404)),
|
||||
('get_aci_image', 'GET', ANOTHER_ORG_REPO, False, ACI_ARGS, (403, 403, 403, 403, 404)),
|
||||
|
||||
# get_squashed_tag
|
||||
('get_squashed_tag', 'GET', PUBLIC_REPO, False, dict(tag='fake'), (404, 404, 404, 404, 404)),
|
||||
('get_squashed_tag', 'GET', PRIVATE_REPO, False, dict(tag='fake'), (403, 403, 404, 403, 404)),
|
||||
('get_squashed_tag', 'GET', ORG_REPO, False, dict(tag='fake'), (403, 403, 404, 403, 404)),
|
||||
('get_squashed_tag', 'GET', ANOTHER_ORG_REPO, False, dict(tag='fake'), (403, 403, 403, 403,
|
||||
404)),
|
||||
|
||||
# get_tag_torrent
|
||||
('get_tag_torrent', 'GET', PUBLIC_REPO, True, dict(digest='sha256:1234'), (404, 404, 404, 404,
|
||||
404)),
|
||||
('get_tag_torrent', 'GET', PRIVATE_REPO, True, dict(digest='sha256:1234'), (403, 403, 404, 403,
|
||||
404)),
|
||||
('get_tag_torrent', 'GET', ORG_REPO, True, dict(digest='sha256:1234'), (403, 403, 404, 403,
|
||||
404)),
|
||||
('get_tag_torrent', 'GET', ANOTHER_ORG_REPO, True, dict(digest='sha256:1234'), (403, 403, 403,
|
||||
403, 404)),])
|
||||
def test_verbs_security(user, endpoint, method, repository, single_repo_path, params,
|
||||
expected_statuses, app, client):
|
||||
headers = {}
|
||||
if user[1] is not None:
|
||||
headers['Authorization'] = gen_basic_auth(user[1], 'password')
|
||||
|
||||
if single_repo_path:
|
||||
params['repository'] = repository
|
||||
else:
|
||||
(namespace, repo_name) = repository.split('/')
|
||||
params['namespace'] = namespace
|
||||
params['repository'] = repo_name
|
||||
|
||||
conduct_call(client, 'verbs.' + endpoint, url_for, method, params,
|
||||
expected_code=expected_statuses[user[0]], headers=headers)
|
Reference in a new issue