""" Create, list and manage build triggers. """ import json import logging from os import path from urllib import quote from urlparse import urlunparse from flask import request, url_for from app import app from auth.permissions import (UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission, AdministerRepositoryPermission) from buildtrigger.basehandler import BuildTriggerHandler from buildtrigger.triggerutil import (TriggerDeactivationException, TriggerActivationException, EmptyRepositoryException, RepositoryReadException, TriggerStartException) from data import model from data.model.build import update_build_trigger from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, log_action, request_error, query_param, parse_args, internal_only, validate_json_request, api, path_param, abort, disallow_for_app_repositories) from endpoints.exception import NotFound, Unauthorized, InvalidRequest from endpoints.api.build import build_status_view, trigger_view, RepositoryBuildStatus from endpoints.building import start_build, MaximumBuildsQueuedException from endpoints.exception import NotFound, Unauthorized, InvalidRequest from util.dockerfileparse import parse_dockerfile from util.names import parse_robot_username logger = logging.getLogger(__name__) def _prepare_webhook_url(scheme, username, password, hostname, path): auth_hostname = '%s:%s@%s' % (quote(username), quote(password), hostname) return urlunparse((scheme, auth_hostname, path, '', '', '')) @resource('/v1/repository//trigger/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') class BuildTriggerList(RepositoryParamResource): """ Resource for listing repository build triggers. """ @require_repo_admin @disallow_for_app_repositories @nickname('listBuildTriggers') def get(self, namespace_name, repo_name): """ List the triggers for the specified repository. """ triggers = model.build.list_build_triggers(namespace_name, repo_name) return { 'triggers': [trigger_view(trigger, can_admin=True) for trigger in triggers] } @resource('/v1/repository//trigger/') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') class BuildTrigger(RepositoryParamResource): """ Resource for managing specific build triggers. """ @require_repo_admin @disallow_for_app_repositories @nickname('getBuildTrigger') def get(self, namespace_name, repo_name, trigger_uuid): """ Get information for the specified build trigger. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() return trigger_view(trigger, can_admin=True) @require_repo_admin @disallow_for_app_repositories @nickname('deleteBuildTrigger') def delete(self, namespace_name, repo_name, trigger_uuid): """ Delete the specified build trigger. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerHandler.get_handler(trigger) if handler.is_active(): try: handler.deactivate() except TriggerDeactivationException as ex: # We are just going to eat this error logger.warning('Trigger deactivation problem: %s', ex) log_action('delete_repo_trigger', namespace_name, {'repo': repo_name, 'trigger_id': trigger_uuid, 'service': trigger.service.name}, repo=model.repository.get_repository(namespace_name, repo_name)) trigger.delete_instance(recursive=True) if trigger.write_token is not None: trigger.write_token.delete_instance() return 'No Content', 204 @resource('/v1/repository//trigger//subdir') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerSubdirs(RepositoryParamResource): """ Custom verb for fetching the subdirs which are buildable for a trigger. """ schemas = { 'BuildTriggerSubdirRequest': { 'type': 'object', 'description': 'Arbitrary json.', }, } @require_repo_admin @disallow_for_app_repositories @nickname('listBuildTriggerSubdirs') @validate_json_request('BuildTriggerSubdirRequest') def post(self, namespace_name, repo_name, trigger_uuid): """ List the subdirectories available for the specified build trigger and source. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): new_config_dict = request.get_json() handler = BuildTriggerHandler.get_handler(trigger, new_config_dict) try: subdirs = handler.list_build_subdirs() context_map = {} for file in subdirs: context_map = handler.get_parent_directory_mappings(file, context_map) return { 'dockerfile_paths': ['/' + subdir for subdir in subdirs], 'contextMap': context_map, 'status': 'success', } except EmptyRepositoryException as exc: return { 'status': 'success', 'contextMap': {}, 'dockerfile_paths': [], } except RepositoryReadException as exc: return { 'status': 'error', 'message': exc.message, } else: raise Unauthorized() @resource('/v1/repository//trigger//activate') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') class BuildTriggerActivate(RepositoryParamResource): """ Custom verb for activating a build trigger once all required information has been collected. """ schemas = { 'BuildTriggerActivateRequest': { 'type': 'object', 'required': [ 'config' ], 'properties': { 'config': { 'type': 'object', 'description': 'Arbitrary json.', }, 'pull_robot': { 'type': 'string', 'description': 'The name of the robot that will be used to pull images.' } } }, } @require_repo_admin @disallow_for_app_repositories @nickname('activateBuildTrigger') @validate_json_request('BuildTriggerActivateRequest') def post(self, namespace_name, repo_name, trigger_uuid): """ Activate the specified build trigger. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerHandler.get_handler(trigger) if handler.is_active(): raise InvalidRequest('Trigger config is not sufficient for activation.') user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): # Update the pull robot (if any). pull_robot_name = request.get_json().get('pull_robot', None) if pull_robot_name: try: pull_robot = model.user.lookup_robot(pull_robot_name) except model.InvalidRobotException: raise NotFound() # Make sure the user has administer permissions for the robot's namespace. (robot_namespace, _) = parse_robot_username(pull_robot_name) if not AdministerOrganizationPermission(robot_namespace).can(): raise Unauthorized() # Make sure the namespace matches that of the trigger. if robot_namespace != namespace_name: raise Unauthorized() # Set the pull robot. trigger.pull_robot = pull_robot # Update the config. new_config_dict = request.get_json()['config'] write_token_name = 'Build Trigger: %s' % trigger.service.name write_token = model.token.create_delegate_token(namespace_name, repo_name, write_token_name, 'write') try: path = url_for('webhooks.build_trigger_webhook', trigger_uuid=trigger.uuid) authed_url = _prepare_webhook_url(app.config['PREFERRED_URL_SCHEME'], '$token', write_token.code, app.config['SERVER_HOSTNAME'], path) handler = BuildTriggerHandler.get_handler(trigger, new_config_dict) final_config, private_config = handler.activate(authed_url) if 'private_key' in private_config: trigger.private_key = private_config['private_key'] except TriggerActivationException as exc: write_token.delete_instance() raise request_error(message=exc.message) # Save the updated config. update_build_trigger(trigger, final_config, write_token=write_token) # Log the trigger setup. repo = model.repository.get_repository(namespace_name, repo_name) log_action('setup_repo_trigger', namespace_name, {'repo': repo_name, 'namespace': namespace_name, 'trigger_id': trigger.uuid, 'service': trigger.service.name, 'pull_robot': trigger.pull_robot.username if trigger.pull_robot else None, 'config': final_config}, repo=repo) return trigger_view(trigger, can_admin=True) else: raise Unauthorized() @resource('/v1/repository//trigger//analyze') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerAnalyze(RepositoryParamResource): """ Custom verb for analyzing the config for a build trigger and suggesting various changes (such as a robot account to use for pulling) """ schemas = { 'BuildTriggerAnalyzeRequest': { 'type': 'object', 'required': [ 'config' ], 'properties': { 'config': { 'type': 'object', 'description': 'Arbitrary json.', } } }, } @require_repo_admin @disallow_for_app_repositories @nickname('analyzeBuildTrigger') @validate_json_request('BuildTriggerAnalyzeRequest') def post(self, namespace_name, repo_name, trigger_uuid): """ Analyze the specified build trigger configuration. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() if trigger.repository.namespace_user.username != namespace_name: raise NotFound() if trigger.repository.name != repo_name: raise NotFound() new_config_dict = request.get_json()['config'] handler = BuildTriggerHandler.get_handler(trigger, new_config_dict) def analyze_view(image_namespace, image_repository, status, message=None): # Retrieve the list of robots and mark whether they have read access already. robots = [] if AdministerOrganizationPermission(image_namespace).can(): if image_repository is not None: perm_query = model.user.get_all_repo_users_transitive(image_namespace, image_repository) user_ids_with_permission = set([user.id for user in perm_query]) else: user_ids_with_permission = set() def robot_view(robot): return { 'name': robot.username, 'kind': 'user', 'is_robot': True, 'can_read': robot.id in user_ids_with_permission, } robots = [robot_view(robot) for robot in model.user.list_namespace_robots(image_namespace)] return { 'namespace': image_namespace, 'name': image_repository, 'robots': robots, 'status': status, 'message': message, 'is_admin': AdministerOrganizationPermission(image_namespace).can(), } try: # Load the contents of the Dockerfile. contents = handler.load_dockerfile_contents() if not contents: return { 'status': 'warning', 'message': 'Specified Dockerfile path for the trigger was not found on the main ' + 'branch. This trigger may fail.', } # Parse the contents of the Dockerfile. parsed = parse_dockerfile(contents) if not parsed: return { 'status': 'error', 'message': 'Could not parse the Dockerfile specified' } # Check whether the dockerfile_path is correct if new_config_dict.get('context'): if not is_parent(new_config_dict.get('context'), new_config_dict.get('dockerfile_path')): return { 'status': 'error', 'message': 'Dockerfile, %s, is not child of the context, %s.' % (new_config_dict.get('context'), new_config_dict.get('dockerfile_path')) } # Default to the current namespace. base_namespace = namespace_name base_repository = None # Determine the base image (i.e. the FROM) for the Dockerfile. base_image = parsed.get_base_image() if not base_image: return analyze_view(base_namespace, base_repository, 'warning', message='No FROM line found in the Dockerfile') # Check to see if the base image lives in Quay. quay_registry_prefix = '%s/' % (app.config['SERVER_HOSTNAME']) if not base_image.startswith(quay_registry_prefix): return analyze_view(base_namespace, base_repository, 'publicbase') # Lookup the repository in Quay. result = str(base_image)[len(quay_registry_prefix):].split('/', 2) if len(result) != 2: msg = '"%s" is not a valid Quay repository path' % (base_image) return analyze_view(base_namespace, base_repository, 'warning', message=msg) (base_namespace, base_repository) = result found_repository = model.repository.get_repository(base_namespace, base_repository) if not found_repository: return { 'status': 'error', 'message': 'Repository "%s" referenced by the Dockerfile was not found' % (base_image) } # If the repository is private and the user cannot see that repo, then # mark it as not found. can_read = ReadRepositoryPermission(base_namespace, base_repository) if found_repository.visibility.name != 'public' and not can_read: return { 'status': 'error', 'message': 'Repository "%s" referenced by the Dockerfile was not found' % (base_image) } if found_repository.visibility.name == 'public': return analyze_view(base_namespace, base_repository, 'publicbase') else: return analyze_view(base_namespace, base_repository, 'requiresrobot') except RepositoryReadException as rre: return { 'status': 'error', 'message': 'Could not analyze the repository: %s' % rre.message, } except NotImplementedError: return { 'status': 'notimplemented', } raise NotFound() def is_parent(context, dockerfile_path): """ This checks whether the context is a parent of the dockerfile_path""" if context == "" or dockerfile_path == "": return False normalized_context = path.normpath(context) if normalized_context[len(normalized_context) - 1] != path.sep: normalized_context += path.sep if normalized_context[0] != path.sep: normalized_context = path.sep + normalized_context normalized_subdir = path.normpath(path.dirname(dockerfile_path)) if normalized_subdir[0] != path.sep: normalized_subdir = path.sep + normalized_subdir if normalized_subdir[len(normalized_subdir) - 1] != path.sep: normalized_subdir += path.sep return normalized_subdir.startswith(normalized_context) @resource('/v1/repository//trigger//start') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') class ActivateBuildTrigger(RepositoryParamResource): """ Custom verb to manually activate a build trigger. """ schemas = { 'RunParameters': { 'type': 'object', 'description': 'Optional run parameters for activating the build trigger', 'properties': { 'branch_name': { 'type': 'string', 'description': '(SCM only) If specified, the name of the branch to build.' }, 'commit_sha': { 'type': 'string', 'description': '(Custom Only) If specified, the ref/SHA1 used to checkout a git repository.' }, 'refs': { 'type': ['object', 'null'], 'description': '(SCM Only) If specified, the ref to build.' } }, 'additionalProperties': False } } @require_repo_admin @disallow_for_app_repositories @nickname('manuallyStartBuildTrigger') @validate_json_request('RunParameters') def post(self, namespace_name, repo_name, trigger_uuid): """ Manually start a build from the specified trigger. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerHandler.get_handler(trigger) if not handler.is_active(): raise InvalidRequest('Trigger is not active.') try: repo = model.repository.get_repository(namespace_name, repo_name) pull_robot_name = model.build.get_pull_robot_name(trigger) run_parameters = request.get_json() prepared = handler.manual_start(run_parameters=run_parameters) build_request = start_build(repo, prepared, pull_robot_name=pull_robot_name) except TriggerStartException as tse: raise InvalidRequest(tse.message) except MaximumBuildsQueuedException: abort(429, message='Maximum queued build rate exceeded.') resp = build_status_view(build_request) repo_string = '%s/%s' % (namespace_name, repo_name) headers = { 'Location': api.url_for(RepositoryBuildStatus, repository=repo_string, build_uuid=build_request.uuid), } return resp, 201, headers @resource('/v1/repository//trigger//builds') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') class TriggerBuildList(RepositoryParamResource): """ Resource to represent builds that were activated from the specified trigger. """ @require_repo_admin @disallow_for_app_repositories @parse_args() @query_param('limit', 'The maximum number of builds to return', type=int, default=5) @nickname('listTriggerRecentBuilds') def get(self, namespace_name, repo_name, trigger_uuid, parsed_args): """ List the builds started by the specified trigger. """ limit = parsed_args['limit'] builds = model.build.list_trigger_builds(namespace_name, repo_name, trigger_uuid, limit) return { 'builds': [build_status_view(bld) for bld in builds] } FIELD_VALUE_LIMIT = 30 @resource('/v1/repository//trigger//fields/') @internal_only class BuildTriggerFieldValues(RepositoryParamResource): """ Custom verb to fetch a values list for a particular field name. """ @require_repo_admin @disallow_for_app_repositories @nickname('listTriggerFieldValues') def post(self, namespace_name, repo_name, trigger_uuid, field_name): """ List the field values for a custom run field. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() config = request.get_json() or None if AdministerRepositoryPermission(namespace_name, repo_name).can(): handler = BuildTriggerHandler.get_handler(trigger, config) values = handler.list_field_values(field_name, limit=FIELD_VALUE_LIMIT) if values is None: raise NotFound() return { 'values': values } else: raise Unauthorized() @resource('/v1/repository//trigger//sources') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerSources(RepositoryParamResource): """ Custom verb to fetch the list of build sources for the trigger config. """ schemas = { 'BuildTriggerSourcesRequest': { 'type': 'object', 'description': 'Specifies the namespace under which to fetch sources', 'properties': { 'namespace': { 'type': 'string', 'description': 'The namespace for which to fetch sources' }, }, } } @require_repo_admin @disallow_for_app_repositories @nickname('listTriggerBuildSources') @validate_json_request('BuildTriggerSourcesRequest') def post(self, namespace_name, repo_name, trigger_uuid): """ List the build sources for the trigger configuration thus far. """ namespace = request.get_json()['namespace'] try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): handler = BuildTriggerHandler.get_handler(trigger) try: return { 'sources': handler.list_build_sources_for_namespace(namespace) } except RepositoryReadException as rre: raise InvalidRequest(rre.message) else: raise Unauthorized() @resource('/v1/repository//trigger//namespaces') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('trigger_uuid', 'The UUID of the build trigger') @internal_only class BuildTriggerSourceNamespaces(RepositoryParamResource): """ Custom verb to fetch the list of namespaces (orgs, projects, etc) for the trigger config. """ @require_repo_admin @disallow_for_app_repositories @nickname('listTriggerBuildSourceNamespaces') def get(self, namespace_name, repo_name, trigger_uuid): """ List the build sources for the trigger configuration thus far. """ try: trigger = model.build.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): handler = BuildTriggerHandler.get_handler(trigger) try: return { 'namespaces': handler.list_build_source_namespaces() } except RepositoryReadException as rre: raise InvalidRequest(rre.message) else: raise Unauthorized()