""" 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 import model, oci_model 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.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'] existing = model.repository.get_repository(namespace_name, repository_name) if existing: 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' repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility, repo_kind=kind) repo.description = req['description'] repo.save() log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo) 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 next_page_token = None repos = None repo_kind = parsed_args['repo_kind'] # Lookup the requested repositories (either starred or non-starred.) if parsed_args['starred']: if not username: # No repositories should be returned, as there is no user. abort(400) # Return the full list of repos starred by the current user that are still visible to them. def can_view_repo(repo): return ReadRepositoryPermission(repo.namespace_user.username, repo.name).can() unfiltered_repos = model.repository.get_user_starred_repositories(user, kind_filter=repo_kind) repos = [repo for repo in unfiltered_repos if can_view_repo(repo)] elif parsed_args['namespace']: # Repositories filtered by namespace do not need pagination (their results are fairly small), # so we just do the lookup directly. repos = list(model.repository.get_visible_repositories(username=username, include_public=parsed_args['public'], namespace=parsed_args['namespace'], kind_filter=repo_kind)) else: # Determine the starting offset for pagination. Note that we don't use the normal # model.modelutil.paginate method here, as that does not operate over UNION queries, which # get_visible_repositories will return if there is a logged-in user (for performance reasons). # # Also note the +1 on the limit, as paginate_query uses the extra result to determine whether # there is a next page. start_id = model.modelutil.pagination_start(page_token) repo_query = model.repository.get_visible_repositories(username=username, include_public=parsed_args['public'], start_id=start_id, limit=REPOS_PER_PAGE+1, kind_filter=repo_kind) repos, next_page_token = model.modelutil.paginate_query(repo_query, limit=REPOS_PER_PAGE, id_alias='rid') # Collect the IDs of the repositories found for subequent lookup of popularity # and/or last modified. if parsed_args['last_modified'] or parsed_args['popularity']: repository_ids = [repo.rid for repo in repos] if parsed_args['last_modified']: last_modified_map = model.repository.get_when_last_modified(repository_ids) if parsed_args['popularity']: action_sum_map = model.log.get_repositories_action_sums(repository_ids) # Collect the IDs of the repositories that are starred for the user, so we can mark them # in the returned results. star_set = set() if username: starred_repos = model.repository.get_user_starred_repositories(user) star_set = {starred.id for starred in starred_repos} def repo_view(repo_obj): repo = { 'namespace': repo_obj.namespace_user.username, 'name': repo_obj.name, 'description': repo_obj.description, 'is_public': repo_obj.visibility_id == model.repository.get_public_repo_visibility().id, 'kind': repo_kind, } repo_id = repo_obj.rid if parsed_args['last_modified']: repo['last_modified'] = last_modified_map.get(repo_id) if parsed_args['popularity']: repo['popularity'] = float(action_sum_map.get(repo_id, 0)) if username: repo['is_starred'] = repo_id in star_set return repo return { 'repositories': [repo_view(repo) for repo in repos] }, next_page_token @resource('/v1/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) @require_repo_read @nickname('getRepo') def get(self, namespace, repository, parsed_args): """Fetch the specified repository.""" logger.debug('Get repo: %s/%s' % (namespace, repository)) repo = model.repository.get_repository(namespace, repository) if repo is None: raise NotFound() can_write = ModifyRepositoryPermission(namespace, repository).can() can_admin = AdministerRepositoryPermission(namespace, repository).can() is_starred = (model.repository.repository_is_starred(get_authenticated_user(), repo) if get_authenticated_user() else False) is_public = model.repository.is_repository_public(repo) # Note: This is *temporary* code for the new OCI model stuff. if repo.kind.name == 'application': def channel_view(channel): return { 'name': channel.name, 'release': channel.linked_tag.name, 'last_modified': format_date(datetime.fromtimestamp(channel.linked_tag.lifetime_start / 1000)), } def release_view(release): return { 'name': release.name, 'last_modified': format_date(datetime.fromtimestamp(release.lifetime_start / 1000)), 'channels': releases_channels_map[release.name], } channels = oci_model.channel.get_repo_channels(repo) releases_channels_map = defaultdict(list) for channel in channels: releases_channels_map[channel.linked_tag.name].append(channel.name) repo_data = { 'namespace': namespace, 'name': repository, 'kind': repo.kind.name, 'description': repo.description, 'can_write': can_write, 'can_admin': can_admin, 'is_public': is_public, 'is_organization': repo.namespace_user.organization, 'is_starred': is_starred, 'channels': [channel_view(chan) for chan in channels], 'releases': [release_view(release) for release in oci_model.release.get_release_objs(repo)], } return repo_data # Older image-only repo code. def tag_view(tag): tag_info = { 'name': tag.name, 'image_id': tag.image.docker_image_id, 'size': tag.image.aggregate_size } if tag.lifetime_start_ts > 0: last_modified = format_date(datetime.fromtimestamp(tag.lifetime_start_ts)) tag_info['last_modified'] = last_modified if tag.tagmanifest is not None: tag_info['manifest_digest'] = tag.tagmanifest.digest return tag_info stats = None tags = model.tag.list_active_repo_tags(repo) tag_dict = {tag.name: tag_view(tag) for tag in tags} if parsed_args['includeStats']: stats = [] found_dates = {} start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS) counts = model.log.get_repository_action_counts(repo, start_date) for count in counts: stats.append({ 'date': count.date.isoformat(), 'count': count.count, }) 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 not key in found_dates: stats.append({ 'date': day_date.date().isoformat(), 'count': 0, }) repo_data = { 'namespace': namespace, 'name': repository, 'kind': repo.kind.name, 'description': repo.description, 'tags': tag_dict, 'can_write': can_write, 'can_admin': can_admin, 'is_public': is_public, 'is_organization': repo.namespace_user.organization, 'is_starred': is_starred, 'status_token': repo.badge_token if not is_public else '', 'trust_enabled': bool(features.SIGNING) and repo.trust_enabled, 'tag_expiration_s': repo.namespace_user.removed_tag_expiration_s, } if stats is not None: 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. """ repo = model.repository.get_repository(namespace, repository) if repo: values = request.get_json() repo.description = values['description'] repo.save() log_action('set_repo_description', namespace, {'repo': repository, 'namespace': namespace, 'description': values['description']}, repo=repo) return { 'success': True } raise NotFound() @require_repo_admin @nickname('deleteRepository') def delete(self, namespace, repository): """ Delete a repository. """ model.repository.purge_repository(namespace, repository) user = model.user.get_namespace_user(namespace) if features.BILLING: plan = get_namespace_plan(namespace) check_repository_usage(user, 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//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. """ repo = model.repository.get_repository(namespace, repository) if repo: values = request.get_json() visibility = values['visibility'] if visibility == 'private': check_allowed_private_repos(namespace) model.repository.set_repository_visibility(repo, visibility) log_action('change_repo_visibility', namespace, {'repo': repository, 'namespace': namespace, 'visibility': values['visibility']}, repo=repo) return {'success': True} @resource('/v1/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. """ repo = model.repository.get_repository(namespace, repository) if not repo: 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({'message': 'Unable to delete downstream trust metadata'}) values = request.get_json() model.repository.set_trust(repo, values['trust_enabled']) log_action('change_repo_trust', namespace, {'repo': repository, 'namespace': namespace, 'trust_enabled': values['trust_enabled']}, repo=repo) return {'success': True}