405 lines
14 KiB
Python
405 lines
14 KiB
Python
|
""" List, create and manage repositories. """
|
||
|
|
||
|
import logging
|
||
|
import datetime
|
||
|
import features
|
||
|
|
||
|
from collections import defaultdict
|
||
|
from datetime import timedelta, datetime
|
||
|
|
||
|
from flask import request, abort
|
||
|
|
||
|
from app import dockerfile_build_queue, tuf_metadata_api
|
||
|
from data.database import RepositoryState
|
||
|
from endpoints.api import (
|
||
|
format_date, nickname, log_action, validate_json_request, require_repo_read, require_repo_write,
|
||
|
require_repo_admin, RepositoryParamResource, resource, parse_args, ApiResource, request_error,
|
||
|
require_scope, path_param, page_support, query_param, truthy_bool, show_if)
|
||
|
from endpoints.api.repository_models_pre_oci import pre_oci_model as model
|
||
|
from endpoints.exception import (
|
||
|
Unauthorized, NotFound, InvalidRequest, ExceedsLicenseException, DownstreamIssue)
|
||
|
from endpoints.api.billing import lookup_allowed_private_repos, get_namespace_plan
|
||
|
from endpoints.api.subscribe import check_repository_usage
|
||
|
|
||
|
from auth.permissions import (ModifyRepositoryPermission, AdministerRepositoryPermission,
|
||
|
CreateRepositoryPermission, ReadRepositoryPermission)
|
||
|
from auth.auth_context import get_authenticated_user
|
||
|
from auth import scopes
|
||
|
from util.names import REPOSITORY_NAME_REGEX
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
REPOS_PER_PAGE = 100
|
||
|
MAX_DAYS_IN_3_MONTHS = 92
|
||
|
|
||
|
|
||
|
def check_allowed_private_repos(namespace):
|
||
|
""" Checks to see if the given namespace has reached its private repository limit. If so,
|
||
|
raises a ExceedsLicenseException.
|
||
|
"""
|
||
|
# Not enabled if billing is disabled.
|
||
|
if not features.BILLING:
|
||
|
return
|
||
|
|
||
|
if not lookup_allowed_private_repos(namespace):
|
||
|
raise ExceedsLicenseException()
|
||
|
|
||
|
|
||
|
@resource('/v1/repository')
|
||
|
class RepositoryList(ApiResource):
|
||
|
"""Operations for creating and listing repositories."""
|
||
|
schemas = {
|
||
|
'NewRepo': {
|
||
|
'type': 'object',
|
||
|
'description': 'Description of a new repository',
|
||
|
'required': [
|
||
|
'repository',
|
||
|
'visibility',
|
||
|
'description',
|
||
|
],
|
||
|
'properties': {
|
||
|
'repository': {
|
||
|
'type': 'string',
|
||
|
'description': 'Repository name',
|
||
|
},
|
||
|
'visibility': {
|
||
|
'type': 'string',
|
||
|
'description': 'Visibility which the repository will start with',
|
||
|
'enum': [
|
||
|
'public',
|
||
|
'private',
|
||
|
],
|
||
|
},
|
||
|
'namespace': {
|
||
|
'type':
|
||
|
'string',
|
||
|
'description': ('Namespace in which the repository should be created. If omitted, the '
|
||
|
'username of the caller is used'),
|
||
|
},
|
||
|
'description': {
|
||
|
'type': 'string',
|
||
|
'description': 'Markdown encoded description for the repository',
|
||
|
},
|
||
|
'repo_kind': {
|
||
|
'type': ['string', 'null'],
|
||
|
'description': 'The kind of repository',
|
||
|
'enum': ['image', 'application', None],
|
||
|
}
|
||
|
},
|
||
|
},
|
||
|
}
|
||
|
|
||
|
@require_scope(scopes.CREATE_REPO)
|
||
|
@nickname('createRepo')
|
||
|
@validate_json_request('NewRepo')
|
||
|
def post(self):
|
||
|
"""Create a new repository."""
|
||
|
owner = get_authenticated_user()
|
||
|
req = request.get_json()
|
||
|
|
||
|
if owner is None and 'namespace' not in 'req':
|
||
|
raise InvalidRequest('Must provide a namespace or must be logged in.')
|
||
|
|
||
|
namespace_name = req['namespace'] if 'namespace' in req else owner.username
|
||
|
|
||
|
permission = CreateRepositoryPermission(namespace_name)
|
||
|
if permission.can():
|
||
|
repository_name = req['repository']
|
||
|
visibility = req['visibility']
|
||
|
|
||
|
if model.repo_exists(namespace_name, repository_name):
|
||
|
raise request_error(message='Repository already exists')
|
||
|
|
||
|
visibility = req['visibility']
|
||
|
if visibility == 'private':
|
||
|
check_allowed_private_repos(namespace_name)
|
||
|
|
||
|
# Verify that the repository name is valid.
|
||
|
if not REPOSITORY_NAME_REGEX.match(repository_name):
|
||
|
raise InvalidRequest('Invalid repository name')
|
||
|
|
||
|
kind = req.get('repo_kind', 'image') or 'image'
|
||
|
model.create_repo(namespace_name, repository_name, owner, req['description'],
|
||
|
visibility=visibility, repo_kind=kind)
|
||
|
|
||
|
log_action('create_repo', namespace_name,
|
||
|
{'repo': repository_name,
|
||
|
'namespace': namespace_name}, repo_name=repository_name)
|
||
|
return {
|
||
|
'namespace': namespace_name,
|
||
|
'name': repository_name,
|
||
|
'kind': kind,
|
||
|
}, 201
|
||
|
|
||
|
raise Unauthorized()
|
||
|
|
||
|
@require_scope(scopes.READ_REPO)
|
||
|
@nickname('listRepos')
|
||
|
@parse_args()
|
||
|
@query_param('namespace', 'Filters the repositories returned to this namespace', type=str)
|
||
|
@query_param('starred', 'Filters the repositories returned to those starred by the user',
|
||
|
type=truthy_bool, default=False)
|
||
|
@query_param('public', 'Adds any repositories visible to the user by virtue of being public',
|
||
|
type=truthy_bool, default=False)
|
||
|
@query_param('last_modified', 'Whether to include when the repository was last modified.',
|
||
|
type=truthy_bool, default=False)
|
||
|
@query_param('popularity', 'Whether to include the repository\'s popularity metric.',
|
||
|
type=truthy_bool, default=False)
|
||
|
@query_param('repo_kind', 'The kind of repositories to return', type=str, default='image')
|
||
|
@page_support()
|
||
|
def get(self, page_token, parsed_args):
|
||
|
""" Fetch the list of repositories visible to the current user under a variety of situations.
|
||
|
"""
|
||
|
# Ensure that the user requests either filtered by a namespace, only starred repositories,
|
||
|
# or public repositories. This ensures that the user is not requesting *all* visible repos,
|
||
|
# which can cause a surge in DB CPU usage.
|
||
|
if not parsed_args['namespace'] and not parsed_args['starred'] and not parsed_args['public']:
|
||
|
raise InvalidRequest('namespace, starred or public are required for this API call')
|
||
|
|
||
|
user = get_authenticated_user()
|
||
|
username = user.username if user else None
|
||
|
last_modified = parsed_args['last_modified']
|
||
|
popularity = parsed_args['popularity']
|
||
|
|
||
|
if parsed_args['starred'] and not username:
|
||
|
# No repositories should be returned, as there is no user.
|
||
|
abort(400)
|
||
|
|
||
|
repos, next_page_token = model.get_repo_list(
|
||
|
parsed_args['starred'], user, parsed_args['repo_kind'], parsed_args['namespace'], username,
|
||
|
parsed_args['public'], page_token, last_modified, popularity)
|
||
|
|
||
|
return {'repositories': [repo.to_dict() for repo in repos]}, next_page_token
|
||
|
|
||
|
|
||
|
@resource('/v1/repository/<apirepopath:repository>')
|
||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||
|
class Repository(RepositoryParamResource):
|
||
|
"""Operations for managing a specific repository."""
|
||
|
schemas = {
|
||
|
'RepoUpdate': {
|
||
|
'type': 'object',
|
||
|
'description': 'Fields which can be updated in a repository.',
|
||
|
'required': ['description',],
|
||
|
'properties': {
|
||
|
'description': {
|
||
|
'type': 'string',
|
||
|
'description': 'Markdown encoded description for the repository',
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@parse_args()
|
||
|
@query_param('includeStats', 'Whether to include action statistics', type=truthy_bool,
|
||
|
default=False)
|
||
|
@query_param('includeTags', 'Whether to include repository tags', type=truthy_bool,
|
||
|
default=True)
|
||
|
@require_repo_read
|
||
|
@nickname('getRepo')
|
||
|
def get(self, namespace, repository, parsed_args):
|
||
|
"""Fetch the specified repository."""
|
||
|
logger.debug('Get repo: %s/%s' % (namespace, repository))
|
||
|
include_tags = parsed_args['includeTags']
|
||
|
max_tags = 500
|
||
|
repo = model.get_repo(namespace, repository, get_authenticated_user(), include_tags, max_tags)
|
||
|
if repo is None:
|
||
|
raise NotFound()
|
||
|
|
||
|
has_write_permission = ModifyRepositoryPermission(namespace, repository).can()
|
||
|
has_write_permission = has_write_permission and repo.state == RepositoryState.NORMAL
|
||
|
|
||
|
repo_data = repo.to_dict()
|
||
|
repo_data['can_write'] = has_write_permission
|
||
|
repo_data['can_admin'] = AdministerRepositoryPermission(namespace, repository).can()
|
||
|
|
||
|
if parsed_args['includeStats'] and repo.repository_base_elements.kind_name != 'application':
|
||
|
stats = []
|
||
|
found_dates = {}
|
||
|
|
||
|
for count in repo.counts:
|
||
|
stats.append(count.to_dict())
|
||
|
found_dates['%s/%s' % (count.date.month, count.date.day)] = True
|
||
|
|
||
|
# Fill in any missing stats with zeros.
|
||
|
for day in range(1, MAX_DAYS_IN_3_MONTHS):
|
||
|
day_date = datetime.now() - timedelta(days=day)
|
||
|
key = '%s/%s' % (day_date.month, day_date.day)
|
||
|
if key not in found_dates:
|
||
|
stats.append({
|
||
|
'date': day_date.date().isoformat(),
|
||
|
'count': 0,
|
||
|
})
|
||
|
|
||
|
repo_data['stats'] = stats
|
||
|
return repo_data
|
||
|
|
||
|
@require_repo_write
|
||
|
@nickname('updateRepo')
|
||
|
@validate_json_request('RepoUpdate')
|
||
|
def put(self, namespace, repository):
|
||
|
""" Update the description in the specified repository. """
|
||
|
if not model.repo_exists(namespace, repository):
|
||
|
raise NotFound()
|
||
|
|
||
|
values = request.get_json()
|
||
|
model.set_description(namespace, repository, values['description'])
|
||
|
|
||
|
log_action('set_repo_description', namespace,
|
||
|
{'repo': repository,
|
||
|
'namespace': namespace,
|
||
|
'description': values['description']}, repo_name=repository)
|
||
|
return {'success': True}
|
||
|
|
||
|
@require_repo_admin
|
||
|
@nickname('deleteRepository')
|
||
|
def delete(self, namespace, repository):
|
||
|
""" Delete a repository. """
|
||
|
username = model.purge_repository(namespace, repository)
|
||
|
|
||
|
if features.BILLING:
|
||
|
plan = get_namespace_plan(namespace)
|
||
|
model.check_repository_usage(username, plan)
|
||
|
|
||
|
# Remove any builds from the queue.
|
||
|
dockerfile_build_queue.delete_namespaced_items(namespace, repository)
|
||
|
|
||
|
log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace})
|
||
|
return '', 204
|
||
|
|
||
|
|
||
|
@resource('/v1/repository/<apirepopath:repository>/changevisibility')
|
||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||
|
class RepositoryVisibility(RepositoryParamResource):
|
||
|
""" Custom verb for changing the visibility of the repository. """
|
||
|
schemas = {
|
||
|
'ChangeVisibility': {
|
||
|
'type': 'object',
|
||
|
'description': 'Change the visibility for the repository.',
|
||
|
'required': ['visibility',],
|
||
|
'properties': {
|
||
|
'visibility': {
|
||
|
'type': 'string',
|
||
|
'description': 'Visibility which the repository will start with',
|
||
|
'enum': [
|
||
|
'public',
|
||
|
'private',
|
||
|
],
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@require_repo_admin
|
||
|
@nickname('changeRepoVisibility')
|
||
|
@validate_json_request('ChangeVisibility')
|
||
|
def post(self, namespace, repository):
|
||
|
""" Change the visibility of a repository. """
|
||
|
if model.repo_exists(namespace, repository):
|
||
|
values = request.get_json()
|
||
|
visibility = values['visibility']
|
||
|
if visibility == 'private':
|
||
|
check_allowed_private_repos(namespace)
|
||
|
|
||
|
model.set_repository_visibility(namespace, repository, visibility)
|
||
|
log_action('change_repo_visibility', namespace,
|
||
|
{'repo': repository,
|
||
|
'namespace': namespace,
|
||
|
'visibility': values['visibility']}, repo_name=repository)
|
||
|
return {'success': True}
|
||
|
|
||
|
|
||
|
@resource('/v1/repository/<apirepopath:repository>/changetrust')
|
||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||
|
class RepositoryTrust(RepositoryParamResource):
|
||
|
""" Custom verb for changing the trust settings of the repository. """
|
||
|
schemas = {
|
||
|
'ChangeRepoTrust': {
|
||
|
'type': 'object',
|
||
|
'description': 'Change the trust settings for the repository.',
|
||
|
'required': ['trust_enabled',],
|
||
|
'properties': {
|
||
|
'trust_enabled': {
|
||
|
'type': 'boolean',
|
||
|
'description': 'Whether or not signing is enabled for the repository.'
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@show_if(features.SIGNING)
|
||
|
@require_repo_admin
|
||
|
@nickname('changeRepoTrust')
|
||
|
@validate_json_request('ChangeRepoTrust')
|
||
|
def post(self, namespace, repository):
|
||
|
""" Change the visibility of a repository. """
|
||
|
if not model.repo_exists(namespace, repository):
|
||
|
raise NotFound()
|
||
|
|
||
|
tags, _ = tuf_metadata_api.get_default_tags_with_expiration(namespace, repository)
|
||
|
if tags and not tuf_metadata_api.delete_metadata(namespace, repository):
|
||
|
raise DownstreamIssue('Unable to delete downstream trust metadata')
|
||
|
|
||
|
values = request.get_json()
|
||
|
model.set_trust(namespace, repository, values['trust_enabled'])
|
||
|
|
||
|
log_action(
|
||
|
'change_repo_trust', namespace,
|
||
|
{'repo': repository,
|
||
|
'namespace': namespace,
|
||
|
'trust_enabled': values['trust_enabled']}, repo_name=repository)
|
||
|
|
||
|
return {'success': True}
|
||
|
|
||
|
|
||
|
@resource('/v1/repository/<apirepopath:repository>/changestate')
|
||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||
|
@show_if(features.REPO_MIRROR)
|
||
|
class RepositoryStateResource(RepositoryParamResource):
|
||
|
""" Custom verb for changing the state of the repository. """
|
||
|
schemas = {
|
||
|
'ChangeRepoState': {
|
||
|
'type': 'object',
|
||
|
'description': 'Change the state of the repository.',
|
||
|
'required': ['state'],
|
||
|
'properties': {
|
||
|
'state': {
|
||
|
'type': 'string',
|
||
|
'description': 'Determines whether pushes are allowed.',
|
||
|
'enum': ['NORMAL', 'READ_ONLY', 'MIRROR'],
|
||
|
},
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
@require_repo_admin
|
||
|
@nickname('changeRepoState')
|
||
|
@validate_json_request('ChangeRepoState')
|
||
|
def put(self, namespace, repository):
|
||
|
""" Change the state of a repository. """
|
||
|
if not model.repo_exists(namespace, repository):
|
||
|
raise NotFound()
|
||
|
|
||
|
values = request.get_json()
|
||
|
state_name = values['state']
|
||
|
|
||
|
try:
|
||
|
state = RepositoryState[state_name]
|
||
|
except KeyError:
|
||
|
state = None
|
||
|
|
||
|
if state == RepositoryState.MIRROR and not features.REPO_MIRROR:
|
||
|
return {'detail': 'Unknown Repository State: %s' % state_name}, 400
|
||
|
|
||
|
if state is None:
|
||
|
return {'detail': '%s is not a valid Repository state.' % state_name}, 400
|
||
|
|
||
|
model.set_repository_state(namespace, repository, state)
|
||
|
|
||
|
log_action('change_repo_state', namespace,
|
||
|
{'repo': repository,
|
||
|
'namespace': namespace,
|
||
|
'state_changed': state_name}, repo_name=repository)
|
||
|
|
||
|
return {'success': True}
|