diff --git a/endpoints/api/__init__.py b/endpoints/api/__init__.py index c411cf091..e38a6b47e 100644 --- a/endpoints/api/__init__.py +++ b/endpoints/api/__init__.py @@ -175,4 +175,7 @@ import endpoints.api.legacy import endpoints.api.repository import endpoints.api.discovery -import endpoints.api.user \ No newline at end of file +import endpoints.api.user +import endpoints.api.search +import endpoints.api.build +import endpoints.api.webhook diff --git a/endpoints/api/build.py b/endpoints/api/build.py new file mode 100644 index 000000000..ace627433 --- /dev/null +++ b/endpoints/api/build.py @@ -0,0 +1,150 @@ +import logging +import json + +from flask import request, url_for +from flask.ext.restful import abort + +from app import app +from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource, + require_repo_read, require_repo_write, validate_json_request) +from endpoints.common import start_build +from data import model +from auth.permissions import ModifyRepositoryPermission + + +logger = logging.getLogger(__name__) +user_files = app.config['USERFILES'] +build_logs = app.config['BUILDLOGS'] + + +def build_status_view(build_obj, can_write=False): + status = build_logs.get_status(build_obj.uuid) + logger.debug('Can write: %s job_config: %s', can_write, build_obj.job_config) + build_obj.job_config = None + resp = { + 'id': build_obj.uuid, + 'phase': build_obj.phase, + 'started': build_obj.started, + 'display_name': build_obj.display_name, + 'status': status, + 'job_config': json.loads(build_obj.job_config) if can_write else None, + 'is_writer': can_write, + 'trigger': trigger_view(build_obj.trigger), + 'resource_key': build_obj.resource_key, + } + if can_write: + resp['archive_url'] = user_files.get_file_url(build.resource_key) + + return resp + + +@resource('/v1/repository/<path:repository>/build/') +class RepositoryBuildList(RepositoryParamResource): + """ Resource related to creating and listing repository builds. """ + schemas = { + 'RepositoryBuildRequest': { + 'id': 'RepositoryBuildRequest', + 'type': 'object', + 'description': 'Description of a new repository build.', + 'required': True, + 'properties': { + 'file_id': { + 'type': 'string', + 'description': 'The file id that was generated when the build spec was uploaded', + 'required': True, + }, + 'subdirectory': { + 'type': 'string', + 'description': 'Subdirectory in which the Dockerfile can be found', + }, + }, + }, + } + + @parse_args + @query_param('limit', 'The maximum number of builds to return', type=int, default=5) + @require_repo_read + @nickname('getRepoBuilds') + def get(self, args, namespace, repository): + """ Get the list of repository builds. """ + limit = args['limit'] + builds = list(model.list_repository_builds(namespace, repository, limit)) + + can_write = ModifyRepositoryPermission(namespace, repository).can() + return { + 'builds': [build_status_view(build, can_write) for build in builds] + } + + @require_repo_write + @nickname('requestRepoBuild') + @validate_json_request('RepositoryBuildRequest') + def post(self, namespace, repository): + """ Request that a repository be built and pushed from the specified input. """ + logger.debug('User requested repository initialization.') + request_json = request.get_json() + + dockerfile_id = request_json['file_id'] + subdir = request_json['subdirectory'] if 'subdirectory' in request_json else '' + + # Check if the dockerfile resource has already been used. If so, then it + # can only be reused if the user has access to the repository for which it + # was used. + associated_repository = model.get_repository_for_resource(dockerfile_id) + if associated_repository: + if not ModifyRepositoryPermission(associated_repository.namespace, + associated_repository.name): + abort(403) + + # Start the build. + repo = model.get_repository(namespace, repository) + display_name = user_files.get_file_checksum(dockerfile_id) + + build_request = start_build(repo, dockerfile_id, ['latest'], display_name, + subdir, True) + + resp = build_status_view(build_request, True) + repo_string = '%s/%s' % (namespace, repository) + headers = { + 'Location': url_for('api_bp.get_repo_build_status', repository=repo_string, + build_uuid=build_request.uuid), + } + return resp, 201, headers + + +@resource('/v1/repository/<path:repository>/build/<build_uuid>/status') +class RepositoryBuildStatus(RepositoryParamResource): + """ Resource for dealing with repository build status. """ + @require_repo_read + @nickname('getRepoBuildStatus') + def get(self, namespace, repository, build_uuid): + """ Return the status for the builds specified by the build uuids. """ + build = model.get_repository_build(namespace, repository, build_uuid) + if not build: + abort(404) + + can_write = ModifyRepositoryPermission(namespace, repository).can() + return build_status_view(build, can_write) + + +@resource('/repository/<path:repository>/build/<build_uuid>/logs') +class RepositoryBuildLogs(RepositoryParamResource): + """ Resource for loading repository build logs. """ + @require_repo_write + @nickname('getRepoBuildLogs') + def get(self, namespace, repository, build_uuid): + """ Return the build logs for the build specified by the build uuid. """ + response_obj = {} + + build = model.get_repository_build(namespace, repository, build_uuid) + + start = int(request.args.get('start', 0)) + + count, logs = build_logs.get_log_entries(build.uuid, start) + + response_obj.update({ + 'start': start, + 'total': count, + 'logs': [log for log in logs], + }) + + return response_obj diff --git a/endpoints/api/legacy.py b/endpoints/api/legacy.py index 60917ebea..c2c75b7f1 100644 --- a/endpoints/api/legacy.py +++ b/endpoints/api/legacy.py @@ -339,6 +339,7 @@ def request_recovery_email(): return make_response('Created', 201) +# Ported @api_bp.route('/entities/<prefix>', methods=['GET']) @api_login_required def get_matching_entities(prefix): @@ -919,6 +920,7 @@ def create_repo(): abort(403) +# Ported @api_bp.route('/find/repository', methods=['GET']) def find_repos(): prefix = request.args.get('query', '') @@ -1158,6 +1160,7 @@ def build_status_view(build_obj, can_write=False): } +# Ported @api_bp.route('/repository/<path:repository>/build/', methods=['GET']) @parse_repository_name def get_repo_builds(namespace, repository): @@ -1175,6 +1178,7 @@ def get_repo_builds(namespace, repository): abort(403) # Permission denied +# Ported @api_bp.route('/repository/<path:repository>/build/<build_uuid>/status', methods=['GET']) @parse_repository_name @@ -1192,6 +1196,7 @@ def get_repo_build_status(namespace, repository, build_uuid): abort(403) # Permission denied +# Ported and merged with status @api_bp.route('/repository/<path:repository>/build/<build_uuid>/archiveurl', methods=['GET']) @parse_repository_name @@ -1210,6 +1215,7 @@ def get_repo_build_archive_url(namespace, repository, build_uuid): abort(403) # Permission denied +# Ported @api_bp.route('/repository/<path:repository>/build/<build_uuid>/logs', methods=['GET']) @parse_repository_name @@ -1235,6 +1241,7 @@ def get_repo_build_logs(namespace, repository, build_uuid): abort(403) # Permission denied +# Ported @api_bp.route('/repository/<path:repository>/build/', methods=['POST']) @api_login_required @parse_repository_name @@ -1281,6 +1288,7 @@ def webhook_view(webhook): } +# Ported @api_bp.route('/repository/<path:repository>/webhook/', methods=['POST']) @api_login_required @parse_repository_name @@ -1302,6 +1310,7 @@ def create_webhook(namespace, repository): abort(403) # Permissions denied +# Ported @api_bp.route('/repository/<path:repository>/webhook/<public_id>', methods=['GET']) @api_login_required @@ -1319,6 +1328,7 @@ def get_webhook(namespace, repository, public_id): abort(403) # Permission denied +# Ported @api_bp.route('/repository/<path:repository>/webhook/', methods=['GET']) @api_login_required @parse_repository_name @@ -1333,6 +1343,7 @@ def list_webhooks(namespace, repository): abort(403) # Permission denied +# Ported @api_bp.route('/repository/<path:repository>/webhook/<public_id>', methods=['DELETE']) @api_login_required diff --git a/endpoints/api/repository.py b/endpoints/api/repository.py index 7710c8c0e..cf49ff71e 100644 --- a/endpoints/api/repository.py +++ b/endpoints/api/repository.py @@ -53,8 +53,8 @@ class RepositoryList(ApiResource): 'type': 'string', 'description': 'Markdown encoded description for the repository', }, - } - } + }, + }, } @require_scope(scopes.CREATE_REPO) diff --git a/endpoints/api/search.py b/endpoints/api/search.py new file mode 100644 index 000000000..d409d39ff --- /dev/null +++ b/endpoints/api/search.py @@ -0,0 +1,105 @@ +from endpoints.api import ApiResource, parse_args, query_param, truthy_bool, nickname, resource +from data import model +from auth.permissions import OrganizationMemberPermission, ViewTeamPermission +from auth.auth_context import get_authenticated_user + + +@resource('/v1/entities/<prefix>') +class EntitySearch(ApiResource): + """ Resource for searching entities. """ + @parse_args + @query_param('namespace', 'Namespace to use when querying for org entities.', type=str, + default='') + @query_param('includeTeams', 'Whether to include team names.', type=truthy_bool, default=False) + @nickname('getMatchingEntities') + def get(self, args, prefix): + """ Get a list of entities that match the specified prefix. """ + teams = [] + + namespace_name = args['namespace'] + robot_namespace = None + organization = None + + try: + organization = model.get_organization(namespace_name) + + # namespace name was an org + permission = OrganizationMemberPermission(namespace_name) + if permission.can(): + robot_namespace = namespace_name + + if args['includeTeams']: + teams = model.get_matching_teams(prefix, organization) + + except model.InvalidOrganizationException: + # namespace name was a user + if get_authenticated_user().username == namespace_name: + robot_namespace = namespace_name + + users = model.get_matching_users(prefix, robot_namespace, organization) + + def entity_team_view(team): + result = { + 'name': team.name, + 'kind': 'team', + 'is_org_member': True + } + return result + + def user_view(user): + user_json = { + 'name': user.username, + 'kind': 'user', + 'is_robot': user.is_robot, + } + + if organization is not None: + user_json['is_org_member'] = user.is_robot or user.is_org_member + + return user_json + + team_data = [entity_team_view(team) for team in teams] + user_data = [user_view(user) for user in users] + + return { + 'results': team_data + user_data + } + + + def team_view(orgname, team): + view_permission = ViewTeamPermission(orgname, team.name) + role = model.get_team_org_role(team).name + return { + 'id': team.id, + 'name': team.name, + 'description': team.description, + 'can_view': view_permission.can(), + 'role': role + } + + +@resource('/v1/find/repository') +class FindRepositories(ApiResource): + """ Resource for finding repositories. """ + @parse_args + @query_param('query', 'The prefix to use when querying for repositories.', type=str, default='') + @nickname('findRepos') + def get(self, args): + """ Get a list of repositories that match the specified prefix query. """ + prefix = args['query'] + + def repo_view(repo): + return { + 'namespace': repo.namespace, + 'name': repo.name, + 'description': repo.description + } + + username = None + if get_authenticated_user() is not None: + username = get_authenticated_user().username + + matching = model.get_matching_repositories(prefix, username) + return { + 'repositories': [repo_view(repo) for repo in matching] + } \ No newline at end of file diff --git a/endpoints/api/user.py b/endpoints/api/user.py index 1c0bc6c7e..abbf62dca 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -156,6 +156,7 @@ class User(ApiResource): @nickname('createNewUser') @validate_json_request('NewUser') def post(self): + """ Create a new user. """ user_data = request.get_json() existing_user = model.get_user(user_data['username']) diff --git a/endpoints/api/webhook.py b/endpoints/api/webhook.py new file mode 100644 index 000000000..b59919b2e --- /dev/null +++ b/endpoints/api/webhook.py @@ -0,0 +1,69 @@ +from flask import request, url_for +from flask.ext.restful import abort + +from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, + log_action) +from data import model + + +def webhook_view(webhook): + return { + 'public_id': webhook.public_id, + 'parameters': json.loads(webhook.parameters), + } + + +@resource('/v1/repository/<path:repository>/webhook/') +class WebhookList(RepositoryParamResource): + """ Resource for dealing with listing and creating webhooks. """ + + @require_repo_admin + @nickname('createWebhook') + def post(self, namespace, repository): + """ Create a new webhook for the specified repository. """ + repo = model.get_repository(namespace, repository) + webhook = model.create_webhook(repo, request.get_json()) + resp = webhook_view(webhook) + repo_string = '%s/%s' % (namespace, repository) + headers = { + 'Location': url_for('api_bp.get_webhook', repository=repo_string, + public_id=webhook.public_id), + } + log_action('add_repo_webhook', namespace, + {'repo': repository, 'webhook_id': webhook.public_id}, + repo=repo) + return resp, 201, headers + + @require_repo_admin + @nickname('listWebhooks') + def get(self, namespace, repository): + """ List the webhooks for the specified repository. """ + webhooks = model.list_webhooks(namespace, repository) + return { + 'webhooks': [webhook_view(webhook) for webhook in webhooks] + } + + +@resource('/v1/repository/<path:repository>/webhook/<public_id>') +class Webhook(RepositoryParamResource): + """ Resource for dealing with specific webhooks. """ + @require_repo_admin + @nickname('getWebhook') + def get(self, namespace, repository, public_id): + """ Get information for the specified webhook. """ + try: + webhook = model.get_webhook(namespace, repository, public_id) + except model.InvalidWebhookException: + abort(404) + + return webhook_view(webhook) + + @require_repo_admin + @nickname('deleteWebhook') + def delete(self, namespace, repository, public_id): + """ Delete the specified webhook. """ + model.delete_webhook(namespace, repository, public_id) + log_action('delete_repo_webhook', namespace, + {'repo': repository, 'webhook_id': public_id}, + repo=model.get_repository(namespace, repository)) + return make_response('No Content', 204)