import json import logging from flask import request, url_for from urllib import quote from urlparse import urlunparse from app import app from endpoints.api import (RepositoryParamResource, nickname, resource, require_repo_admin, log_action, request_error, query_param, parse_args, internal_only, validate_json_request, api, Unauthorized, NotFound, InvalidRequest, path_param) from endpoints.api.build import (build_status_view, trigger_view, RepositoryBuildStatus, get_trigger_config) from endpoints.common import start_build from endpoints.trigger import (BuildTrigger as BuildTriggerBase, TriggerDeactivationException, TriggerActivationException, EmptyRepositoryException, RepositoryReadException, TriggerStartException) from data import model from auth.permissions import UserAdminPermission, AdministerOrganizationPermission, ReadRepositoryPermission from util.names import parse_robot_username from util.dockerfileparse import parse_dockerfile from util.ssh import generate_ssh_keypair 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 @nickname('listBuildTriggers') def get(self, namespace, repository): """ List the triggers for the specified repository. """ triggers = model.list_build_triggers(namespace, repository) return { 'triggers': [trigger_view(trigger) 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 @nickname('getBuildTrigger') def get(self, namespace, repository, trigger_uuid): """ Get information for the specified build trigger. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() return trigger_view(trigger) @require_repo_admin @nickname('deleteBuildTrigger') def delete(self, namespace, repository, trigger_uuid): """ Delete the specified build trigger. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) config_dict = get_trigger_config(trigger) if handler.is_active(config_dict): try: handler.deactivate(trigger.auth_token, config_dict) 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, {'repo': repository, 'trigger_id': trigger_uuid, 'service': trigger.service.name, 'config': config_dict}, repo=model.get_repository(namespace, repository)) 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': { 'id': 'BuildTriggerSubdirRequest', 'type': 'object', 'description': 'Arbitrary json.', }, } @require_repo_admin @nickname('listBuildTriggerSubdirs') @validate_json_request('BuildTriggerSubdirRequest') def post(self, namespace, repository, trigger_uuid): """ List the subdirectories available for the specified build trigger and source. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): new_config_dict = request.get_json() try: subdirs = handler.list_build_subdirs(trigger.auth_token, new_config_dict) return { 'subdir': subdirs, 'status': 'success' } except EmptyRepositoryException as exc: return { 'status': 'success', 'subdir': [] } 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': { 'id': '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 @nickname('activateBuildTrigger') @validate_json_request('BuildTriggerActivateRequest') def post(self, namespace, repository, trigger_uuid): """ Activate the specified build trigger. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) existing_config_dict = get_trigger_config(trigger) if handler.is_active(existing_config_dict): 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: pull_robot = model.lookup_robot(pull_robot_name) if not pull_robot: raise NotFound() # Make sure the user has administer permissions for the robot's namespace. (robot_namespace, shortname) = 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: raise Unauthorized() # Set the pull robot. trigger.pull_robot = pull_robot # Update the config. new_config_dict = request.get_json()['config'] token_name = 'Build Trigger: %s' % trigger.service.name token = model.create_delegate_token(namespace, repository, token_name, 'write') # Generate an SSH keypair new_config_dict['public_key'], trigger.private_key = generate_ssh_keypair() try: path = url_for('webhooks.build_trigger_webhook', trigger_uuid=trigger.uuid) authed_url = _prepare_webhook_url(app.config['PREFERRED_URL_SCHEME'], '$token', token.code, app.config['SERVER_HOSTNAME'], path) final_config = handler.activate(trigger.uuid, authed_url, trigger.auth_token, new_config_dict) except TriggerActivationException as exc: token.delete_instance() raise request_error(message=exc.message) # Save the updated config. trigger.config = json.dumps(final_config) trigger.write_token = token trigger.save() # Log the trigger setup. repo = model.get_repository(namespace, repository) log_action('setup_repo_trigger', namespace, {'repo': repository, 'namespace': namespace, '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) 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': { 'id': 'BuildTriggerAnalyzeRequest', 'type': 'object', 'required': [ 'config' ], 'properties': { 'config': { 'type': 'object', 'description': 'Arbitrary json.', } } }, } @require_repo_admin @nickname('analyzeBuildTrigger') @validate_json_request('BuildTriggerAnalyzeRequest') def post(self, namespace, repository, trigger_uuid): """ Analyze the specified build trigger configuration. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) new_config_dict = request.get_json()['config'] try: # Load the contents of the Dockerfile. contents = handler.load_dockerfile_contents(trigger.auth_token, new_config_dict) if not contents: return { 'status': 'error', 'message': 'Could not read the Dockerfile for the trigger' } # Parse the contents of the Dockerfile. parsed = parse_dockerfile(contents) if not parsed: return { 'status': 'error', 'message': 'Could not parse the Dockerfile specified' } # Determine the base image (i.e. the FROM) for the Dockerfile. base_image = parsed.get_base_image() if not base_image: return { 'status': '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 { 'status': 'publicbase' } # Lookup the repository in Quay. result = base_image[len(quay_registry_prefix):].split('/', 2) if len(result) != 2: return { 'status': 'warning', 'message': '"%s" is not a valid Quay repository path' % (base_image) } (base_namespace, base_repository) = result found_repository = model.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) } # Check to see if the repository is public. If not, we suggest the # usage of a robot account to conduct the pull. read_robots = [] if AdministerOrganizationPermission(base_namespace).can(): def robot_view(robot): return { 'name': robot.username, 'kind': 'user', 'is_robot': True } def is_valid_robot(user): # Make sure the user is a robot. if not user.robot: return False # Make sure the current user can see/administer the robot. (robot_namespace, shortname) = parse_robot_username(user.username) return AdministerOrganizationPermission(robot_namespace).can() repo_users = list(model.get_all_repo_users_transitive(base_namespace, base_repository)) read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)] return { 'namespace': base_namespace, 'name': base_repository, 'is_public': found_repository.visibility.name == 'public', 'robots': read_robots, 'status': 'analyzed', 'dockerfile_url': handler.dockerfile_url(trigger.auth_token, new_config_dict) } except RepositoryReadException as rre: return { 'status': 'error', 'message': rre.message } raise NotFound() @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': { 'id': 'RunParameters', 'type': 'object', 'description': 'Optional run parameters for activating the build trigger', 'additional_properties': False, 'properties': { 'branch_name': { 'type': 'string', 'description': '(GitHub Only) If specified, the name of the GitHub branch to build.' } } } } @require_repo_admin @nickname('manuallyStartBuildTrigger') @validate_json_request('RunParameters') def post(self, namespace, repository, trigger_uuid): """ Manually start a build from the specified trigger. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) config_dict = get_trigger_config(trigger) if not handler.is_active(config_dict): raise InvalidRequest('Trigger is not active.') try: run_parameters = request.get_json() specs = handler.manual_start(trigger.auth_token, config_dict, run_parameters=run_parameters) dockerfile_id, tags, name, subdir, metadata = specs repo = model.get_repository(namespace, repository) pull_robot_name = model.get_pull_robot_name(trigger) build_request = start_build(repo, dockerfile_id, tags, name, subdir, True, trigger=trigger, pull_robot_name=pull_robot_name, trigger_metadata=metadata) except TriggerStartException as tse: raise InvalidRequest(tse.message) resp = build_status_view(build_request, True) repo_string = '%s/%s' % (namespace, repository) 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 @parse_args @query_param('limit', 'The maximum number of builds to return', type=int, default=5) @nickname('listTriggerRecentBuilds') def get(self, args, namespace, repository, trigger_uuid): """ List the builds started by the specified trigger. """ limit = args['limit'] builds = list(model.list_trigger_builds(namespace, repository, trigger_uuid, limit)) return { 'builds': [build_status_view(build, True) for build in builds] } @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 @nickname('listTriggerFieldValues') def post(self, namespace, repository, trigger_uuid, field_name): """ List the field values for a custom run field. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() config = request.get_json() or json.loads(trigger.config) user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): trigger_handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) values = trigger_handler.list_field_values(trigger.auth_token, config, field_name) 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. """ @require_repo_admin @nickname('listTriggerBuildSources') def get(self, namespace, repository, trigger_uuid): """ List the build sources for the trigger configuration thus far. """ try: trigger = model.get_build_trigger(trigger_uuid) except model.InvalidBuildTriggerException: raise NotFound() user_permission = UserAdminPermission(trigger.connected_user.username) if user_permission.can(): trigger_handler = BuildTriggerBase.get_trigger_for_service(trigger.service.name) return { 'sources': trigger_handler.list_build_sources(trigger.auth_token) } else: raise Unauthorized()