This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/buildtrigger/githubhandler.py
2015-11-04 11:19:49 -05:00

515 lines
17 KiB
Python

import logging
import os.path
import base64
from app import app, github_trigger
from jsonschema import validate
from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException,
TriggerDeactivationException, TriggerStartException,
EmptyRepositoryException, ValidationRequestException,
SkipRequestException, InvalidPayloadException,
determine_build_ref, raise_if_skipped_build,
find_matching_branches)
from buildtrigger.basehandler import BuildTriggerHandler
from util.security.ssh import generate_ssh_keypair
from util.dict_wrappers import JSONPathDict, SafeDictSetter
from github import (Github, UnknownObjectException, GithubException,
BadCredentialsException as GitHubBadCredentialsException)
logger = logging.getLogger(__name__)
GITHUB_WEBHOOK_PAYLOAD_SCHEMA = {
'type': 'object',
'properties': {
'ref': {
'type': 'string',
},
'head_commit': {
'type': 'object',
'properties': {
'id': {
'type': 'string',
},
'url': {
'type': 'string',
},
'message': {
'type': 'string',
},
'timestamp': {
'type': 'string',
},
'author': {
'type': 'object',
'properties': {
'username': {
'type': 'string'
},
'html_url': {
'type': 'string'
},
'avatar_url': {
'type': 'string'
},
},
},
'committer': {
'type': 'object',
'properties': {
'username': {
'type': 'string'
},
'html_url': {
'type': 'string'
},
'avatar_url': {
'type': 'string'
},
},
},
},
'required': ['id', 'url', 'message', 'timestamp'],
},
'repository': {
'type': 'object',
'properties': {
'ssh_url': {
'type': 'string',
},
},
'required': ['ssh_url'],
},
},
'required': ['ref', 'head_commit', 'repository'],
}
def get_transformed_webhook_payload(gh_payload, default_branch=None, lookup_user=None):
""" Returns the GitHub webhook JSON payload transformed into our own payload
format. If the gh_payload is not valid, returns None.
"""
try:
validate(gh_payload, GITHUB_WEBHOOK_PAYLOAD_SCHEMA)
except Exception as exc:
raise InvalidPayloadException(exc.message)
payload = JSONPathDict(gh_payload)
config = SafeDictSetter()
config['commit'] = payload['head_commit.id']
config['ref'] = payload['ref']
config['default_branch'] = default_branch
config['git_url'] = payload['repository.ssh_url']
config['commit_info.url'] = payload['head_commit.url']
config['commit_info.message'] = payload['head_commit.message']
config['commit_info.date'] = payload['head_commit.timestamp']
config['commit_info.author.username'] = payload['head_commit.author.username']
config['commit_info.author.url'] = payload.get('head_commit.author.html_url')
config['commit_info.author.avatar_url'] = payload.get('head_commit.author.avatar_url')
config['commit_info.committer.username'] = payload.get('head_commit.committer.username')
config['commit_info.committer.url'] = payload.get('head_commit.committer.html_url')
config['commit_info.committer.avatar_url'] = payload.get('head_commit.committer.avatar_url')
# Note: GitHub doesn't always return the extra information for users, so we do the lookup
# manually if possible.
if (lookup_user and not payload.get('head_commit.author.html_url') and
payload.get('head_commit.author.username')):
author_info = lookup_user(payload['head_commit.author.username'])
if author_info:
config['commit_info.author.url'] = author_info['html_url']
config['commit_info.author.avatar_url'] = author_info['avatar_url']
if (lookup_user and
payload.get('head_commit.committer.username') and
not payload.get('head_commit.committer.html_url')):
committer_info = lookup_user(payload['head_commit.committer.username'])
if committer_info:
config['commit_info.committer.url'] = committer_info['html_url']
config['commit_info.committer.avatar_url'] = committer_info['avatar_url']
return config.dict_value()
class GithubBuildTrigger(BuildTriggerHandler):
"""
BuildTrigger for GitHub that uses the archive API and buildpacks.
"""
def _get_client(self):
""" Returns an authenticated client for talking to the GitHub API. """
return Github(self.auth_token,
base_url=github_trigger.api_endpoint(),
client_id=github_trigger.client_id(),
client_secret=github_trigger.client_secret(),
timeout=5)
@classmethod
def service_name(cls):
return 'github'
def is_active(self):
return 'hook_id' in self.config
def get_repository_url(self):
source = self.config['build_source']
return github_trigger.get_public_url(source)
@staticmethod
def _get_error_message(ghe, default_msg):
if ghe.data.get('errors') and ghe.data['errors'][0].get('message'):
return ghe.data['errors'][0]['message']
return default_msg
def activate(self, standard_webhook_url):
config = self.config
new_build_source = config['build_source']
gh_client = self._get_client()
# Find the GitHub repository.
try:
gh_repo = gh_client.get_repo(new_build_source)
except UnknownObjectException:
msg = 'Unable to find GitHub repository for source: %s' % new_build_source
raise TriggerActivationException(msg)
# Add a deploy key to the GitHub repository.
public_key, private_key = generate_ssh_keypair()
config['credentials'] = [
{
'name': 'SSH Public Key',
'value': public_key,
},
]
try:
deploy_key = gh_repo.create_key('%s Builder' % app.config['REGISTRY_TITLE'],
public_key)
config['deploy_key_id'] = deploy_key.id
except GithubException as ghe:
default_msg = 'Unable to add deploy key to repository: %s' % new_build_source
msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
raise TriggerActivationException(msg)
# Add the webhook to the GitHub repository.
webhook_config = {
'url': standard_webhook_url,
'content_type': 'json',
}
try:
hook = gh_repo.create_hook('web', webhook_config)
config['hook_id'] = hook.id
config['master_branch'] = gh_repo.default_branch
except GithubException as ghe:
default_msg = 'Unable to create webhook on repository: %s' % new_build_source
msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
raise TriggerActivationException(msg)
return config, {'private_key': private_key}
def deactivate(self):
config = self.config
gh_client = self._get_client()
# Find the GitHub repository.
try:
repo = gh_client.get_repo(config['build_source'])
except UnknownObjectException:
msg = 'Unable to find GitHub repository for source: %s' % config['build_source']
raise TriggerDeactivationException(msg)
except GitHubBadCredentialsException:
msg = 'Unable to access repository to disable trigger'
raise TriggerDeactivationException(msg)
# If the trigger uses a deploy key, remove it.
try:
if config['deploy_key_id']:
deploy_key = repo.get_key(config['deploy_key_id'])
deploy_key.delete()
except KeyError:
# There was no config['deploy_key_id'], thus this is an old trigger without a deploy key.
pass
except GithubException as ghe:
default_msg = 'Unable to remove deploy key: %s' % config['deploy_key_id']
msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
raise TriggerDeactivationException(msg)
# Remove the webhook.
try:
hook = repo.get_hook(config['hook_id'])
hook.delete()
except GithubException as ghe:
default_msg = 'Unable to remove hook: %s' % config['hook_id']
msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
raise TriggerDeactivationException(msg)
config.pop('hook_id', None)
self.config = config
return config
def list_build_sources(self):
gh_client = self._get_client()
usr = gh_client.get_user()
try:
repos = usr.get_repos()
except GithubException:
raise RepositoryReadException('Unable to list user repositories')
namespaces = {}
has_non_personal = False
for repository in repos:
namespace = repository.owner.login
if not namespace in namespaces:
is_personal_repo = namespace == usr.login
namespaces[namespace] = {
'personal': is_personal_repo,
'repos': [],
'info': {
'name': namespace,
'avatar_url': repository.owner.avatar_url
}
}
if not is_personal_repo:
has_non_personal = True
namespaces[namespace]['repos'].append(repository.full_name)
# In older versions of GitHub Enterprise, the get_repos call above does not
# return any non-personal repositories. In that case, we need to lookup the
# repositories manually.
# TODO: Remove this once we no longer support GHE versions <= 2.1
if not has_non_personal:
for org in usr.get_orgs():
repo_list = [repo.full_name for repo in org.get_repos(type='member')]
namespaces[org.name] = {
'personal': False,
'repos': repo_list,
'info': {
'name': org.name or org.login,
'avatar_url': org.avatar_url
}
}
entries = list(namespaces.values())
entries.sort(key=lambda e: e['info']['name'])
return entries
def list_build_subdirs(self):
config = self.config
gh_client = self._get_client()
source = config['build_source']
try:
repo = gh_client.get_repo(source)
# Find the first matching branch.
repo_branches = self.list_field_values('branch_name') or []
branches = find_matching_branches(config, repo_branches)
branches = branches or [repo.default_branch or 'master']
default_commit = repo.get_branch(branches[0]).commit
commit_tree = repo.get_git_tree(default_commit.sha, recursive=True)
return [os.path.dirname(elem.path) for elem in commit_tree.tree
if (elem.type == u'blob' and
os.path.basename(elem.path) == u'Dockerfile')]
except GithubException as ghe:
message = ghe.data.get('message', 'Unable to list contents of repository: %s' % source)
if message == 'Branch not found':
raise EmptyRepositoryException()
raise RepositoryReadException(message)
def load_dockerfile_contents(self):
config = self.config
gh_client = self._get_client()
source = config['build_source']
path = self.get_dockerfile_path()
try:
repo = gh_client.get_repo(source)
file_info = repo.get_file_contents(path)
if file_info is None:
return None
content = file_info.content
if file_info.encoding == 'base64':
content = base64.b64decode(content)
return content
except GithubException as ghe:
message = ghe.data.get('message', 'Unable to read Dockerfile: %s' % source)
raise RepositoryReadException(message)
def list_field_values(self, field_name, limit=None):
if field_name == 'refs':
branches = self.list_field_values('branch_name')
tags = self.list_field_values('tag_name')
return ([{'kind': 'branch', 'name': b} for b in branches] +
[{'kind': 'tag', 'name': tag} for tag in tags])
config = self.config
if field_name == 'tag_name':
try:
gh_client = self._get_client()
source = config['build_source']
repo = gh_client.get_repo(source)
gh_tags = repo.get_tags()
if limit:
gh_tags = repo.get_tags()[0:limit]
return [tag.name for tag in gh_tags]
except GitHubBadCredentialsException:
return []
except GithubException:
logger.exception("Got GitHub Exception when trying to list tags for trigger %s",
self.trigger.id)
return []
if field_name == 'branch_name':
try:
gh_client = self._get_client()
source = config['build_source']
repo = gh_client.get_repo(source)
gh_branches = repo.get_branches()
if limit:
gh_branches = repo.get_branches()[0:limit]
branches = [branch.name for branch in gh_branches]
if not repo.default_branch in branches:
branches.insert(0, repo.default_branch)
if branches[0] != repo.default_branch:
branches.remove(repo.default_branch)
branches.insert(0, repo.default_branch)
return branches
except GitHubBadCredentialsException:
return ['master']
except GithubException:
logger.exception("Got GitHub Exception when trying to list branches for trigger %s",
self.trigger.id)
return ['master']
return None
@classmethod
def _build_metadata_for_commit(cls, commit_sha, ref, repo):
try:
commit = repo.get_commit(commit_sha)
except GithubException:
logger.exception('Could not load commit information from GitHub')
return None
commit_info = {
'url': commit.html_url,
'message': commit.commit.message,
'date': commit.last_modified
}
if commit.author:
commit_info['author'] = {
'username': commit.author.login,
'avatar_url': commit.author.avatar_url,
'url': commit.author.html_url
}
if commit.committer:
commit_info['committer'] = {
'username': commit.committer.login,
'avatar_url': commit.committer.avatar_url,
'url': commit.committer.html_url
}
return {
'commit': commit_sha,
'ref': ref,
'default_branch': repo.default_branch,
'git_url': repo.ssh_url,
'commit_info': commit_info
}
def manual_start(self, run_parameters=None):
config = self.config
source = config['build_source']
try:
gh_client = self._get_client()
repo = gh_client.get_repo(source)
default_branch = repo.default_branch
except GithubException as ghe:
msg = GithubBuildTrigger._get_error_message(ghe, 'Unable to start build trigger')
raise TriggerStartException(msg)
def get_branch_sha(branch_name):
branch = repo.get_branch(branch_name)
return branch.commit.sha
def get_tag_sha(tag_name):
tags = {tag.name: tag for tag in repo.get_tags()}
if not tag_name in tags:
raise TriggerStartException('Could not find tag in repository')
return tags[tag_name].commit.sha
# Find the branch or tag to build.
(commit_sha, ref) = determine_build_ref(run_parameters, get_branch_sha, get_tag_sha,
default_branch)
metadata = GithubBuildTrigger._build_metadata_for_commit(commit_sha, ref, repo)
return self.prepare_build(metadata, is_manual=True)
def lookup_user(self, username):
try:
gh_client = self._get_client()
user = gh_client.get_user(username)
return {
'html_url': user.html_url,
'avatar_url': user.avatar_url
}
except GithubException:
return None
def handle_trigger_request(self, request):
# Check the payload to see if we should skip it based on the lack of a head_commit.
payload = request.get_json()
# This is for GitHub's probing/testing.
if 'zen' in payload:
raise ValidationRequestException()
# Lookup the default branch for the repository.
default_branch = None
lookup_user = None
try:
repo_full_name = '%s/%s' % (payload['repository']['owner']['name'],
payload['repository']['name'])
gh_client = self._get_client()
repo = gh_client.get_repo(repo_full_name)
default_branch = repo.default_branch
lookup_user = self.lookup_user
except GitHubBadCredentialsException:
logger.exception('Got GitHub Credentials Exception; Cannot lookup default branch')
except GithubException:
logger.exception("Got GitHub Exception when trying to start trigger %s", self.trigger.id)
raise SkipRequestException()
logger.debug('GitHub trigger payload %s', payload)
metadata = get_transformed_webhook_payload(payload, default_branch=default_branch,
lookup_user=lookup_user)
prepared = self.prepare_build(metadata)
# Check if we should skip this build.
raise_if_skipped_build(prepared, self.config)
return prepared