diff --git a/config.py b/config.py index 6fe1c4042..b2829d196 100644 --- a/config.py +++ b/config.py @@ -113,6 +113,9 @@ class DefaultConfig(object): # Google Config. GOOGLE_LOGIN_CONFIG = None + # Bitbucket Config. + BITBUCKET_TRIGGER_CONFIG = None + # Requests based HTTP client with a large request pool HTTPCLIENT = build_requests_session() @@ -151,6 +154,9 @@ class DefaultConfig(object): # Feature Flag: Whether to support GitHub build triggers. FEATURE_GITHUB_BUILD = False + # Feature Flag: Whether to support Bitbucket build triggers. + FEATURE_BITBUCKET_BUILD = False + # Feature Flag: Dockerfile build support. FEATURE_BUILD_SUPPORT = True diff --git a/data/model/legacy.py b/data/model/legacy.py index 813fe0e67..cd158e6a6 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -2433,12 +2433,21 @@ def log_action(kind_name, user_or_organization_name, performer=None, datetime=timestamp) -def create_build_trigger(repo, service_name, auth_token, user, pull_robot=None): +def update_build_trigger(trigger, config, auth_token=None): + trigger.config = json.dumps(config or {}) + if auth_token is not None: + trigger.auth_token = auth_token + trigger.save() + + +def create_build_trigger(repo, service_name, auth_token, user, pull_robot=None, config=None): + config = config or {} service = BuildTriggerService.get(name=service_name) trigger = RepositoryBuildTrigger.create(repository=repo, service=service, auth_token=auth_token, connected_user=user, - pull_robot=pull_robot) + pull_robot=pull_robot, + config=json.dumps(config)) return trigger diff --git a/endpoints/bitbuckettrigger.py b/endpoints/bitbuckettrigger.py new file mode 100644 index 000000000..1d4a21a98 --- /dev/null +++ b/endpoints/bitbuckettrigger.py @@ -0,0 +1,43 @@ +import logging + +from flask import request, redirect, url_for, Blueprint +from flask.ext.login import current_user + +from endpoints.trigger import BitbucketBuildTrigger +from endpoints.common import route_show_if +from app import app +from data import model +from util.names import parse_repository_name +from util.http import abort +from auth.auth import require_session_login + +import features + +logger = logging.getLogger(__name__) +client = app.config['HTTPCLIENT'] +bitbuckettrigger = Blueprint('bitbuckettrigger', __name__) + + +@bitbuckettrigger.route('/bitbucket/callback/trigger/', methods=['GET']) +@route_show_if(features.BITBUCKET_BUILD) +@require_session_login +def attach_bitbucket_build_trigger(trigger_uuid): + trigger = model.get_build_trigger(trigger_uuid) + if not trigger or trigger.service.name != BitbucketBuildTrigger.service_name(): + abort(404) + + if trigger.connected_user != current_user.db_user(): + abort(404) + + verifier = request.args.get('oauth_verifier') + result = BitbucketBuildTrigger.exchange_verifier(trigger, verifier) + print result + return 'hello' + + repo_path = '%s/%s' % (namespace, repository) + full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=', + trigger.uuid) + + + logger.debug('Redirecting to full url: %s', full_url) + return redirect(full_url) \ No newline at end of file diff --git a/endpoints/githubtrigger.py b/endpoints/githubtrigger.py new file mode 100644 index 000000000..8954e9119 --- /dev/null +++ b/endpoints/githubtrigger.py @@ -0,0 +1,51 @@ +import logging + +from flask import request, redirect, url_for, Blueprint +from flask.ext.login import current_user + +from endpoints.common import route_show_if +from app import app, github_trigger +from data import model +from util.names import parse_repository_name +from util.http import abort +from auth.permissions import AdministerRepositoryPermission +from auth.auth import require_session_login + +import features + +logger = logging.getLogger(__name__) +client = app.config['HTTPCLIENT'] +githubtrigger = Blueprint('callback', __name__) + +@githubtrigger.route('/github/callback/trigger/', methods=['GET']) +@githubtrigger.route('/github/callback/trigger//__new', methods=['GET']) +@route_show_if(features.GITHUB_BUILD) +@require_session_login +@parse_repository_name +def attach_github_build_trigger(namespace, repository): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + code = request.args.get('code') + token = github_trigger.exchange_code_for_token(app.config, client, code) + repo = model.get_repository(namespace, repository) + if not repo: + msg = 'Invalid repository: %s/%s' % (namespace, repository) + abort(404, message=msg) + + trigger = model.create_build_trigger(repo, 'github', token, current_user.db_user()) + + # TODO(jschorr): Remove once the new layout is in place. + admin_path = '%s/%s/%s' % (namespace, repository, 'admin') + full_url = '%s%s%s' % (url_for('web.repository', path=admin_path), '?tab=trigger&new_trigger=', + trigger.uuid) + + if '__new' in request.url: + repo_path = '%s/%s' % (namespace, repository) + full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=', + trigger.uuid) + + + logger.debug('Redirecting to full url: %s', full_url) + return redirect(full_url) + + abort(403) \ No newline at end of file diff --git a/endpoints/callbacks.py b/endpoints/oauthlogin.py similarity index 69% rename from endpoints/callbacks.py rename to endpoints/oauthlogin.py index 4b8215435..7f32ca552 100644 --- a/endpoints/callbacks.py +++ b/endpoints/oauthlogin.py @@ -5,23 +5,19 @@ from flask import request, redirect, url_for, Blueprint from flask.ext.login import current_user from endpoints.common import render_page_template, common_login, route_show_if -from app import app, analytics, get_app_url, github_login, google_login, github_trigger +from app import app, analytics, get_app_url, github_login, google_login from data import model from util.names import parse_repository_name from util.validation import generate_valid_usernames from util.http import abort -from auth.permissions import AdministerRepositoryPermission from auth.auth import require_session_login from peewee import IntegrityError import features logger = logging.getLogger(__name__) - client = app.config['HTTPCLIENT'] - - -callback = Blueprint('callback', __name__) +oauthlogin = Blueprint('oauthlogin', __name__) def render_ologin_error(service_name, error_message='Could not load user data. The token may have expired.'): @@ -30,36 +26,6 @@ def render_ologin_error(service_name, service_url=get_app_url(), user_creation=features.USER_CREATION) -def exchange_code_for_token(code, service, form_encode=False, redirect_suffix=''): - code = request.args.get('code') - payload = { - 'client_id': service.client_id(), - 'client_secret': service.client_secret(), - 'code': code, - 'grant_type': 'authorization_code', - 'redirect_uri': '%s://%s/oauth2/%s/callback%s' % (app.config['PREFERRED_URL_SCHEME'], - app.config['SERVER_HOSTNAME'], - service.service_name().lower(), - redirect_suffix) - } - - headers = { - 'Accept': 'application/json' - } - - token_url = service.token_endpoint() - if form_encode: - get_access_token = client.post(token_url, data=payload, headers=headers) - else: - get_access_token = client.post(token_url, params=payload, headers=headers) - - json_data = get_access_token.json() - if not json_data: - return '' - - token = json_data.get('access_token', '') - return token - def get_user(service, token): token_param = { @@ -129,14 +95,15 @@ def get_google_username(user_data): return username -@callback.route('/google/callback', methods=['GET']) +@oauthlogin.route('/google/callback', methods=['GET']) @route_show_if(features.GOOGLE_LOGIN) def google_oauth_callback(): error = request.args.get('error', None) if error: return render_ologin_error('Google', error) - token = exchange_code_for_token(request.args.get('code'), google_login, form_encode=True) + code = request.args.get('code') + token = google_login.exchange_code_for_token(app.config, client, code, form_encode=True) user_data = get_user(google_login, token) if not user_data or not user_data.get('id', None) or not user_data.get('email', None): return render_ologin_error('Google') @@ -150,7 +117,7 @@ def google_oauth_callback(): metadata=metadata) -@callback.route('/github/callback', methods=['GET']) +@oauthlogin.route('/github/callback', methods=['GET']) @route_show_if(features.GITHUB_LOGIN) def github_oauth_callback(): error = request.args.get('error', None) @@ -158,7 +125,8 @@ def github_oauth_callback(): return render_ologin_error('GitHub', error) # Exchange the OAuth code. - token = exchange_code_for_token(request.args.get('code'), github_login) + code = request.args.get('code') + token = google_login.exchange_code_for_token(app.config, client, code) # Retrieve the user's information. user_data = get_user(github_login, token) @@ -211,12 +179,13 @@ def github_oauth_callback(): return conduct_oauth_login(github_login, github_id, username, found_email, metadata=metadata) -@callback.route('/google/callback/attach', methods=['GET']) +@oauthlogin.route('/google/callback/attach', methods=['GET']) @route_show_if(features.GOOGLE_LOGIN) @require_session_login def google_oauth_attach(): - token = exchange_code_for_token(request.args.get('code'), google_login, - redirect_suffix='/attach', form_encode=True) + code = request.args.get('code') + token = google_login.exchange_code_for_token(app.config, client, code, + redirect_suffix='/attach', form_encode=True) user_data = get_user(google_login, token) if not user_data or not user_data.get('id', None): @@ -240,11 +209,12 @@ def google_oauth_attach(): return redirect(url_for('web.user')) -@callback.route('/github/callback/attach', methods=['GET']) +@oauthlogin.route('/github/callback/attach', methods=['GET']) @route_show_if(features.GITHUB_LOGIN) @require_session_login def github_oauth_attach(): - token = exchange_code_for_token(request.args.get('code'), github_login) + code = request.args.get('code') + token = google_login.exchange_code_for_token(app.config, client, code) user_data = get_user(github_login, token) if not user_data: return render_ologin_error('GitHub') @@ -265,37 +235,4 @@ def github_oauth_attach(): return render_ologin_error('GitHub', err) - return redirect(url_for('web.user')) - - -@callback.route('/github/callback/trigger/', methods=['GET']) -@callback.route('/github/callback/trigger//__new', methods=['GET']) -@route_show_if(features.GITHUB_BUILD) -@require_session_login -@parse_repository_name -def attach_github_build_trigger(namespace, repository): - permission = AdministerRepositoryPermission(namespace, repository) - if permission.can(): - token = exchange_code_for_token(request.args.get('code'), github_trigger) - repo = model.get_repository(namespace, repository) - if not repo: - msg = 'Invalid repository: %s/%s' % (namespace, repository) - abort(404, message=msg) - - trigger = model.create_build_trigger(repo, 'github', token, current_user.db_user()) - - # TODO(jschorr): Remove once the new layout is in place. - admin_path = '%s/%s/%s' % (namespace, repository, 'admin') - full_url = '%s%s%s' % (url_for('web.repository', path=admin_path), '?tab=trigger&new_trigger=', - trigger.uuid) - - if '__new' in request.url: - repo_path = '%s/%s' % (namespace, repository) - full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=', - trigger.uuid) - - - logger.debug('Redirecting to full url: %s', full_url) - return redirect(full_url) - - abort(403) + return redirect(url_for('web.user')) \ No newline at end of file diff --git a/endpoints/trigger.py b/endpoints/trigger.py index 557bb53e6..1368bded8 100644 --- a/endpoints/trigger.py +++ b/endpoints/trigger.py @@ -7,10 +7,11 @@ import re import json from github import Github, UnknownObjectException, GithubException +from bitbucket.bitbucket import Bitbucket from tempfile import SpooledTemporaryFile from jsonschema import validate -from app import app, userfiles as user_files, github_trigger +from app import app, userfiles as user_files, github_trigger, get_app_url from util.tarfileappender import TarfileAppender from util.ssh import generate_ssh_keypair @@ -58,6 +59,9 @@ class EmptyRepositoryException(Exception): class RepositoryReadException(Exception): pass +class TriggerProviderException(Exception): + pass + class BuildTrigger(object): def __init__(self): @@ -158,6 +162,87 @@ def get_trigger_config(trigger): return {} +class BitbucketBuildTrigger(BuildTrigger): + """ + BuildTrigger for Bitbucket. + """ + @classmethod + def service_name(cls): + return 'bitbucket' + + @staticmethod + def _get_authorized_client(trigger_uuid): + key = app.config.get('BITBUCKET_TRIGGER_CONFIG', {}).get('CONSUMER_KEY', '') + secret = app.config.get('BITBUCKET_TRIGGER_CONFIG', {}).get('CONSUMER_SECRET', '') + + callback_url = '%s/oauth1/bitbucket/callback/trigger/%s' % (get_app_url(), trigger_uuid) + bitbucket_client = Bitbucket() + (result, err_message) = bitbucket_client.authorize(key, secret, callback_url) + if not result: + raise TriggerProviderException(err_message) + + return bitbucket_client + + + @staticmethod + def get_oauth_url(trigger_uuid): + bitbucket_client = BitbucketBuildTrigger._get_authorized_client(trigger_uuid) + url = bitbucket_client.url('AUTHENTICATE', token=bitbucket_client.access_token) + return { + 'access_token': bitbucket_client.access_token, + 'access_token_secret': bitbucket_client.access_token_secret, + 'url': url + } + + @staticmethod + def exchange_verifier(trigger, verifier): + trigger_config = get_trigger_config(trigger.config) + bitbucket_client = BitbucketBuildTrigger._get_authorized_client(trigger.uuid) + print trigger.config + print trigger.auth_token + print bitbucket_client.verify(verifier, access_token=trigger_config.get('access_token', ''), + access_token_secret=trigger.auth_token) + return None + #(result, _) = bitbucket_client.verify(verifier) + + #if not result: + # return None + + #return (bitbucket_client.access_token, bitbucket_client.access_token_secret) + + def is_active(self, config): + return False + + def activate(self, trigger_uuid, standard_webhook_url, auth_token, config): + return {} + + def deactivate(self, auth_token, config): + return config + + def list_build_sources(self, auth_token): + return [] + + + def list_build_subdirs(self, auth_token, config): + raise RepositoryReadException('Not supported') + + def dockerfile_url(self, auth_token, config): + return None + + def load_dockerfile_contents(self, auth_token, config): + raise RepositoryReadException('Not supported') + + @staticmethod + def _build_commit_info(repo, commit_sha): + return {} + + def handle_trigger_request(self, request, trigger): + return + + def manual_start(self, trigger, run_parameters=None): + return None + + class GithubBuildTrigger(BuildTrigger): """ BuildTrigger for GitHub that uses the archive API and buildpacks. diff --git a/endpoints/web.py b/endpoints/web.py index 46a0b1502..b90e8ea1e 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -20,7 +20,7 @@ from util.cache import no_cache from endpoints.common import common_login, render_page_template, route_show_if, param_required from endpoints.csrf import csrf_protect, generate_csrf_token, verify_csrf from endpoints.registry import set_cache_headers -from endpoints.trigger import CustomBuildTrigger +from endpoints.trigger import CustomBuildTrigger, BitbucketBuildTrigger, TriggerProviderException from util.names import parse_repository_name, parse_repository_name_and_tag from util.useremails import send_email_changed from util.systemlogs import build_logs_archive @@ -495,6 +495,41 @@ def download_logs_archive(): abort(403) + +@web.route('/bitbucket/setup/', methods=['GET']) +@require_session_login +@parse_repository_name +@route_show_if(features.BITBUCKET_BUILD) +def attach_bitbucket_trigger(namespace, repository_name): + permission = AdministerRepositoryPermission(namespace, repository_name) + if permission.can(): + repo = model.get_repository(namespace, repository_name) + if not repo: + msg = 'Invalid repository: %s/%s' % (namespace, repository_name) + abort(404, message=msg) + + trigger = model.create_build_trigger(repo, BitbucketBuildTrigger.service_name(), + None, + current_user.db_user()) + + try: + oauth_info = BitbucketBuildTrigger.get_oauth_url(trigger.uuid) + + config = { + 'access_token': oauth_info['access_token'] + } + + access_token_secret = oauth_info['access_token_secret'] + model.update_build_trigger(trigger, config, auth_token=access_token_secret) + + return redirect(oauth_info['url']) + except TriggerProviderException: + trigger.delete_instance() + abort(400, message='Could not retrieve OAuth URL from Bitbucket') + + abort(403) + + @web.route('/customtrigger/setup/', methods=['GET']) @require_session_login @parse_repository_name diff --git a/initdb.py b/initdb.py index 7368aedfd..f99547527 100644 --- a/initdb.py +++ b/initdb.py @@ -204,6 +204,7 @@ def initialize_database(): BuildTriggerService.create(name='github') BuildTriggerService.create(name='custom-git') + BuildTriggerService.create(name='bitbucket') AccessTokenKind.create(name='build-worker') AccessTokenKind.create(name='pushpull-token') diff --git a/static/directives/repo-view/repo-panel-builds.html b/static/directives/repo-view/repo-panel-builds.html index 0f96dabed..3079d06f0 100644 --- a/static/directives/repo-view/repo-panel-builds.html +++ b/static/directives/repo-view/repo-panel-builds.html @@ -92,7 +92,7 @@