""" List, create and manage repositories. """ import logging from collections import defaultdict from datetime import datetime, timedelta from flask import abort, request import features from app import ( app, dockerfile_build_queue, repository_gc_queue, tuf_metadata_api, usermanager, ) from auth import scopes from auth.auth_context import get_authenticated_user from auth.permissions import ( AdministerRepositoryPermission, CreateRepositoryPermission, ModifyRepositoryPermission, ReadRepositoryPermission, ) from data.database import RepositoryState from endpoints.api import ( ApiResource, RepositoryParamResource, allow_if_superuser, allow_if_superuser_with_full_access, format_date, log_action, nickname, page_support, parse_args, path_param, query_param, request_error, require_repo_admin, require_repo_read, require_repo_write, require_scope, resource, show_if, validate_json_request, ) from endpoints.api.billing import get_namespace_plan, lookup_allowed_private_repos from endpoints.api.repository_models_pre_oci import pre_oci_model as model from endpoints.api.subscribe import check_repository_usage from endpoints.exception import ( DownstreamIssue, ExceedsLicenseException, InvalidRequest, NotFound, Unauthorized, ) from util.names import REPOSITORY_NAME_EXTENDED_REGEX, REPOSITORY_NAME_REGEX from util.parsing import truthy_bool logger = logging.getLogger(__name__) 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. """ # Superusers with full access bypass license limits if allow_if_superuser_with_full_access(): return # 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() or allow_if_superuser_with_full_access()) and not ( features.RESTRICTED_USERS and usermanager.is_restricted_user(owner.username) and owner.username == namespace_name and owner.username not in app.config.get("SUPER_USERS", None) ): 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 features.EXTENDED_REPOSITORY_NAMES: valid_repository_name = REPOSITORY_NAME_EXTENDED_REGEX.match(repository_name) else: valid_repository_name = REPOSITORY_NAME_REGEX.match(repository_name) if not valid_repository_name: raise InvalidRequest("Invalid repository name") kind = req.get("repo_kind", "image") or "image" created = model.create_repo( namespace_name, repository_name, owner, req["description"], visibility=visibility, repo_kind=kind, ) if created is None: raise InvalidRequest("Could not create repository") 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, ) if features.QUOTA_MANAGEMENT and features.EDIT_QUOTA: repositories_with_view = model.add_quota_view(repos) else: repositories_with_view = [repo.to_dict() for repo in repos] return {"repositories": repositories_with_view}, 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 ) @query_param( "includeTags", "Whether to include repository tags", type=truthy_bool, default=True ) @require_repo_read(allow_for_superuser=True, allow_for_global_readonly_superuser=True) @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(allow_for_superuser=True) @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(allow_for_superuser=True) @nickname("deleteRepository") def delete(self, namespace, repository): """ Delete a repository. """ username = model.mark_repository_for_deletion(namespace, repository, repository_gc_queue) 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//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(allow_for_superuser=True) @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//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(allow_for_superuser=True) @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//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(allow_for_superuser=True) @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}