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_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

View file

@ -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

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 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/<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)
return redirect(url_for('web.user'))

View file

@ -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.

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.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/<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'])
@require_session_login
@parse_repository_name

View file

@ -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')

View file

@ -92,7 +92,7 @@
<b class="caret"></b>
</button>
<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' }}">
<i class="fa fa-lg" ng-class="TriggerService.getMetadata(type).icon"></i>
{{ 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': {
'description': function(config) {
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';
};
triggerService.isEnabled = function(name) {
var type = triggerTypes[name];
if (!type) {
return false;
}
return type['is_enabled']();
};
triggerService.getTitle = function(name) {
var type = triggerTypes[name];
if (!type) {

View file

@ -33,6 +33,36 @@ class OAuthConfig(object):
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):
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.webhooks import webhooks
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(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(webhooks, url_prefix='/webhooks')
application.register_blueprint(realtime, url_prefix='/realtime')