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
2019-11-12 11:09:47 -05:00

587 lines
19 KiB
Python

import logging
import os.path
import base64
import re
from calendar import timegm
from functools import wraps
from ssl import SSLError
from github import (Github, UnknownObjectException, GithubException,
BadCredentialsException as GitHubBadCredentialsException)
from jsonschema import validate
from app import app, github_trigger
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 endpoints.exception import ExternalServiceError
from util.security.ssh import generate_ssh_keypair
from util.dict_wrappers import JSONPathDict, SafeDictSetter
logger = logging.getLogger(__name__)
GITHUB_WEBHOOK_PAYLOAD_SCHEMA = {
'type': 'object',
'properties': {
'ref': {
'type': 'string',
},
'head_commit': {
'type': ['object', 'null'],
'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)
if payload['head_commit'] is None:
raise SkipRequestException
config = SafeDictSetter()
config['commit'] = payload['head_commit.id']
config['ref'] = payload['ref']
config['default_branch'] = payload['repository.default_branch'] or 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()
def _catch_ssl_errors(func):
@wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except SSLError as se:
msg = 'Request to the GitHub API failed: %s' % se.message
logger.exception(msg)
raise ExternalServiceError(msg)
return wrapper
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
@_catch_ssl_errors
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}
@_catch_ssl_errors
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.
if 'hook_id' in config:
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
@_catch_ssl_errors
def list_build_source_namespaces(self):
gh_client = self._get_client()
usr = gh_client.get_user()
# Build the full set of namespaces for the user, starting with their own.
namespaces = {}
namespaces[usr.login] = {
'personal': True,
'id': usr.login,
'title': usr.name or usr.login,
'avatar_url': usr.avatar_url,
'url': usr.html_url,
'score': usr.plan.private_repos if usr.plan else 0,
}
for org in usr.get_orgs():
organization = org.login if org.login else org.name
# NOTE: We don't load the organization's html_url nor its plan, because doing
# so requires loading *each organization* via its own API call in this tight
# loop, which was massively slowing down the load time for users when setting
# up triggers.
namespaces[organization] = {
'personal': False,
'id': organization,
'title': organization,
'avatar_url': org.avatar_url,
'url': '',
'score': 0,
}
return BuildTriggerHandler.build_namespaces_response(namespaces)
@_catch_ssl_errors
def list_build_sources_for_namespace(self, namespace):
def repo_view(repo):
return {
'name': repo.name,
'full_name': repo.full_name,
'description': repo.description or '',
'last_updated': timegm(repo.pushed_at.utctimetuple()) if repo.pushed_at else 0,
'url': repo.html_url,
'has_admin_permissions': repo.permissions.admin,
'private': repo.private,
}
gh_client = self._get_client()
usr = gh_client.get_user()
if namespace == usr.login:
repos = [repo_view(repo) for repo in usr.get_repos(type='owner', sort='updated')]
return BuildTriggerHandler.build_sources_response(repos)
try:
org = gh_client.get_organization(namespace)
if org is None:
return []
except GithubException:
return []
repos = [repo_view(repo) for repo in org.get_repos(type='member')]
return BuildTriggerHandler.build_sources_response(repos)
@_catch_ssl_errors
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 [elem.path for elem in commit_tree.tree
if (elem.type == u'blob' and self.filename_is_dockerfile(os.path.basename(elem.path)))]
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)
@_catch_ssl_errors
def load_dockerfile_contents(self):
config = self.config
gh_client = self._get_client()
source = config['build_source']
try:
repo = gh_client.get_repo(source)
except GithubException as ghe:
message = ghe.data.get('message', 'Unable to list contents of repository: %s' % source)
raise RepositoryReadException(message)
path = self.get_dockerfile_path()
if not path:
return None
try:
file_info = repo.get_contents(path)
# TypeError is needed because directory inputs cause a TypeError
except (GithubException, TypeError) as ghe:
logger.error("got error from trying to find github file %s" % ghe)
return None
if file_info is None:
return None
if isinstance(file_info, list):
return None
content = file_info.content
if file_info.encoding == 'base64':
content = base64.b64decode(content)
return content
@_catch_ssl_errors
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
source = config.get('build_source')
if source is None:
return []
if field_name == 'tag_name':
try:
gh_client = self._get_client()
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()
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
}
@_catch_ssl_errors
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):
try:
branch = repo.get_branch(branch_name)
return branch.commit.sha
except GithubException:
raise TriggerStartException('Could not find branch in repository')
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)
@_catch_ssl_errors
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
@_catch_ssl_errors
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()
if payload is None:
raise InvalidPayloadException('Missing payload')
# This is for GitHub's probing/testing.
if 'zen' in payload:
raise SkipRequestException()
# Lookup the default branch for the repository.
if 'repository' not in payload:
raise InvalidPayloadException("Missing 'repository' on request")
if 'owner' not in payload['repository']:
raise InvalidPayloadException("Missing 'owner' on repository")
if 'name' not in payload['repository']['owner']:
raise InvalidPayloadException("Missing owner 'name' on repository")
if 'name' not in payload['repository']:
raise InvalidPayloadException("Missing 'name' on 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