Work in progress: bitbucket support

This commit is contained in:
Joseph Schorr 2015-04-24 15:13:08 -04:00
parent d180524b23
commit c480fb2105
12 changed files with 321 additions and 86 deletions

View file

@ -113,6 +113,9 @@ class DefaultConfig(object):
# Google Config. # Google Config.
GOOGLE_LOGIN_CONFIG = None GOOGLE_LOGIN_CONFIG = None
# Bitbucket Config.
BITBUCKET_TRIGGER_CONFIG = None
# Requests based HTTP client with a large request pool # Requests based HTTP client with a large request pool
HTTPCLIENT = build_requests_session() HTTPCLIENT = build_requests_session()
@ -151,6 +154,9 @@ class DefaultConfig(object):
# Feature Flag: Whether to support GitHub build triggers. # Feature Flag: Whether to support GitHub build triggers.
FEATURE_GITHUB_BUILD = False FEATURE_GITHUB_BUILD = False
# Feature Flag: Whether to support Bitbucket build triggers.
FEATURE_BITBUCKET_BUILD = False
# Feature Flag: Dockerfile build support. # Feature Flag: Dockerfile build support.
FEATURE_BUILD_SUPPORT = True FEATURE_BUILD_SUPPORT = True

View file

@ -2433,12 +2433,21 @@ def log_action(kind_name, user_or_organization_name, performer=None,
datetime=timestamp) 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) service = BuildTriggerService.get(name=service_name)
trigger = RepositoryBuildTrigger.create(repository=repo, service=service, trigger = RepositoryBuildTrigger.create(repository=repo, service=service,
auth_token=auth_token, auth_token=auth_token,
connected_user=user, connected_user=user,
pull_robot=pull_robot) pull_robot=pull_robot,
config=json.dumps(config))
return trigger return trigger

View file

@ -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/<trigger_uuid>', 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)

View file

@ -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/<path:repository>', methods=['GET'])
@githubtrigger.route('/github/callback/trigger/<path:repository>/__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)

View file

@ -5,23 +5,19 @@ from flask import request, redirect, url_for, Blueprint
from flask.ext.login import current_user from flask.ext.login import current_user
from endpoints.common import render_page_template, common_login, route_show_if 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 data import model
from util.names import parse_repository_name from util.names import parse_repository_name
from util.validation import generate_valid_usernames from util.validation import generate_valid_usernames
from util.http import abort from util.http import abort
from auth.permissions import AdministerRepositoryPermission
from auth.auth import require_session_login from auth.auth import require_session_login
from peewee import IntegrityError from peewee import IntegrityError
import features import features
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
client = app.config['HTTPCLIENT'] client = app.config['HTTPCLIENT']
oauthlogin = Blueprint('oauthlogin', __name__)
callback = Blueprint('callback', __name__)
def render_ologin_error(service_name, def render_ologin_error(service_name,
error_message='Could not load user data. The token may have expired.'): 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(), service_url=get_app_url(),
user_creation=features.USER_CREATION) 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): def get_user(service, token):
token_param = { token_param = {
@ -129,14 +95,15 @@ def get_google_username(user_data):
return username return username
@callback.route('/google/callback', methods=['GET']) @oauthlogin.route('/google/callback', methods=['GET'])
@route_show_if(features.GOOGLE_LOGIN) @route_show_if(features.GOOGLE_LOGIN)
def google_oauth_callback(): def google_oauth_callback():
error = request.args.get('error', None) error = request.args.get('error', None)
if error: if error:
return render_ologin_error('Google', 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) 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): if not user_data or not user_data.get('id', None) or not user_data.get('email', None):
return render_ologin_error('Google') return render_ologin_error('Google')
@ -150,7 +117,7 @@ def google_oauth_callback():
metadata=metadata) metadata=metadata)
@callback.route('/github/callback', methods=['GET']) @oauthlogin.route('/github/callback', methods=['GET'])
@route_show_if(features.GITHUB_LOGIN) @route_show_if(features.GITHUB_LOGIN)
def github_oauth_callback(): def github_oauth_callback():
error = request.args.get('error', None) error = request.args.get('error', None)
@ -158,7 +125,8 @@ def github_oauth_callback():
return render_ologin_error('GitHub', error) return render_ologin_error('GitHub', error)
# Exchange the OAuth code. # 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. # Retrieve the user's information.
user_data = get_user(github_login, token) user_data = get_user(github_login, token)
@ -211,11 +179,12 @@ def github_oauth_callback():
return conduct_oauth_login(github_login, github_id, username, found_email, metadata=metadata) 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) @route_show_if(features.GOOGLE_LOGIN)
@require_session_login @require_session_login
def google_oauth_attach(): def google_oauth_attach():
token = exchange_code_for_token(request.args.get('code'), google_login, code = request.args.get('code')
token = google_login.exchange_code_for_token(app.config, client, code,
redirect_suffix='/attach', form_encode=True) redirect_suffix='/attach', form_encode=True)
user_data = get_user(google_login, token) user_data = get_user(google_login, token)
@ -240,11 +209,12 @@ def google_oauth_attach():
return redirect(url_for('web.user')) 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) @route_show_if(features.GITHUB_LOGIN)
@require_session_login @require_session_login
def github_oauth_attach(): 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) user_data = get_user(github_login, token)
if not user_data: if not user_data:
return render_ologin_error('GitHub') return render_ologin_error('GitHub')
@ -266,36 +236,3 @@ def github_oauth_attach():
return render_ologin_error('GitHub', err) return render_ologin_error('GitHub', err)
return redirect(url_for('web.user')) return redirect(url_for('web.user'))
@callback.route('/github/callback/trigger/<path:repository>', methods=['GET'])
@callback.route('/github/callback/trigger/<path:repository>/__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)

View file

@ -7,10 +7,11 @@ import re
import json import json
from github import Github, UnknownObjectException, GithubException from github import Github, UnknownObjectException, GithubException
from bitbucket.bitbucket import Bitbucket
from tempfile import SpooledTemporaryFile from tempfile import SpooledTemporaryFile
from jsonschema import validate 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.tarfileappender import TarfileAppender
from util.ssh import generate_ssh_keypair from util.ssh import generate_ssh_keypair
@ -58,6 +59,9 @@ class EmptyRepositoryException(Exception):
class RepositoryReadException(Exception): class RepositoryReadException(Exception):
pass pass
class TriggerProviderException(Exception):
pass
class BuildTrigger(object): class BuildTrigger(object):
def __init__(self): def __init__(self):
@ -158,6 +162,87 @@ def get_trigger_config(trigger):
return {} 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): class GithubBuildTrigger(BuildTrigger):
""" """
BuildTrigger for GitHub that uses the archive API and buildpacks. BuildTrigger for GitHub that uses the archive API and buildpacks.

View file

@ -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.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.csrf import csrf_protect, generate_csrf_token, verify_csrf
from endpoints.registry import set_cache_headers 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.names import parse_repository_name, parse_repository_name_and_tag
from util.useremails import send_email_changed from util.useremails import send_email_changed
from util.systemlogs import build_logs_archive from util.systemlogs import build_logs_archive
@ -495,6 +495,41 @@ def download_logs_archive():
abort(403) abort(403)
@web.route('/bitbucket/setup/<path:repository>', 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/<path:repository>', methods=['GET']) @web.route('/customtrigger/setup/<path:repository>', methods=['GET'])
@require_session_login @require_session_login
@parse_repository_name @parse_repository_name

View file

@ -204,6 +204,7 @@ def initialize_database():
BuildTriggerService.create(name='github') BuildTriggerService.create(name='github')
BuildTriggerService.create(name='custom-git') BuildTriggerService.create(name='custom-git')
BuildTriggerService.create(name='bitbucket')
AccessTokenKind.create(name='build-worker') AccessTokenKind.create(name='build-worker')
AccessTokenKind.create(name='pushpull-token') AccessTokenKind.create(name='pushpull-token')

View file

@ -92,7 +92,7 @@
<b class="caret"></b> <b class="caret"></b>
</button> </button>
<ul class="dropdown-menu dropdown-menu-right pull-right"> <ul class="dropdown-menu dropdown-menu-right pull-right">
<li ng-repeat="type in TriggerService.getTypes()"> <li ng-repeat="type in TriggerService.getTypes()" ng-if="TriggerService.isEnabled(type)">
<a href="{{ TriggerService.getRedirectUrl(type, repository.namespace, repository.name) }}" target="{{ TriggerService.getMetadata(type).is_external ? '' : '_self' }}"> <a href="{{ TriggerService.getRedirectUrl(type, repository.namespace, repository.name) }}" target="{{ TriggerService.getMetadata(type).is_external ? '' : '_self' }}">
<i class="fa fa-lg" ng-class="TriggerService.getMetadata(type).icon"></i> <i class="fa fa-lg" ng-class="TriggerService.getMetadata(type).icon"></i>
{{ TriggerService.getTitle(type) }} {{ TriggerService.getTitle(type) }}

View file

@ -52,6 +52,32 @@ angular.module('quay').factory('TriggerService', ['UtilService', '$sanitize', 'K
} }
}, },
'bitbucket': {
'description': function(config) {
var source = UtilService.textToSafeHtml(config['build_source']);
var desc = '<i class="fa fa-bitbucket fa-lg" style="margin-left: 2px; margin-right: 2px"></i> Push to Bitbucket Repository ';
desc += '<a href="https://bitbucket.org/' + source + '" target="_blank">' + source + '</a>';
desc += '<br>Dockerfile folder: //' + UtilService.textToSafeHtml(config['subdir']);
return desc;
},
'run_parameters': [
{
'title': 'Branch',
'type': 'option',
'name': 'branch_name'
}
],
'get_redirect_url': function(namespace, repository) {
return Config.getUrl('/bitbucket/setup/' + namespace + '/' + repository);
},
'is_external': false,
'is_enabled': function() {
return Features.BITBUCKET_BUILD;
},
'icon': 'fa-bitbucket',
'title': function() { return 'Bitbucket Repository Push'; }
},
'custom-git': { 'custom-git': {
'description': function(config) { 'description': function(config) {
var source = UtilService.textToSafeHtml(config['build_source']); var source = UtilService.textToSafeHtml(config['build_source']);
@ -104,6 +130,14 @@ angular.module('quay').factory('TriggerService', ['UtilService', '$sanitize', 'K
return '//' + trigger.config.subdir.replace(new RegExp('(^\/+|\/+$)'), '') + '/Dockerfile'; return '//' + trigger.config.subdir.replace(new RegExp('(^\/+|\/+$)'), '') + '/Dockerfile';
}; };
triggerService.isEnabled = function(name) {
var type = triggerTypes[name];
if (!type) {
return false;
}
return type['is_enabled']();
};
triggerService.getTitle = function(name) { triggerService.getTitle = function(name) {
var type = triggerTypes[name]; var type = triggerTypes[name];
if (!type) { if (!type) {

View file

@ -33,6 +33,36 @@ class OAuthConfig(object):
return endpoint return endpoint
def exchange_code_for_token(self, app_config, http_client, code, form_encode=False,
redirect_suffix=''):
payload = {
'client_id': self.client_id(),
'client_secret': self.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'],
self.service_name().lower(),
redirect_suffix)
}
headers = {
'Accept': 'application/json'
}
token_url = self.token_endpoint()
if form_encode:
get_access_token = http_client.post(token_url, data=payload, headers=headers)
else:
get_access_token = http_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
class GithubOAuthConfig(OAuthConfig): class GithubOAuthConfig(OAuthConfig):
def __init__(self, config, key_name): def __init__(self, config, key_name):

8
web.py
View file

@ -7,10 +7,14 @@ from endpoints.api import api_bp
from endpoints.web import web from endpoints.web import web
from endpoints.webhooks import webhooks from endpoints.webhooks import webhooks
from endpoints.realtime import realtime from endpoints.realtime import realtime
from endpoints.callbacks import callback from endpoints.oauthlogin import oauthlogin
from endpoints.githubtrigger import githubtrigger
from endpoints.bitbuckettrigger import bitbuckettrigger
application.register_blueprint(web) application.register_blueprint(web)
application.register_blueprint(callback, url_prefix='/oauth2') application.register_blueprint(githubtrigger, url_prefix='/oauth2')
application.register_blueprint(oauthlogin, url_prefix='/oauth2')
application.register_blueprint(bitbuckettrigger, url_prefix='/oauth1')
application.register_blueprint(api_bp, url_prefix='/api') application.register_blueprint(api_bp, url_prefix='/api')
application.register_blueprint(webhooks, url_prefix='/webhooks') application.register_blueprint(webhooks, url_prefix='/webhooks')
application.register_blueprint(realtime, url_prefix='/realtime') application.register_blueprint(realtime, url_prefix='/realtime')