500 lines
16 KiB
Python
500 lines
16 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())
|
|
|
|
@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)
|
|
|
|
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:
|
|
msg = 'Unable to add deploy key to repository: %s' % new_build_source
|
|
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:
|
|
msg = 'Unable to create webhook on repository: %s' % new_build_source
|
|
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:
|
|
msg = 'Unable to remove deploy key: %s' % config['deploy_key_id']
|
|
raise TriggerDeactivationException(msg)
|
|
|
|
# Remove the webhook.
|
|
try:
|
|
hook = repo.get_hook(config['hook_id'])
|
|
hook.delete()
|
|
except GithubException:
|
|
msg = 'Unable to remove hook: %s' % config['hook_id']
|
|
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:
|
|
raise TriggerStartException(ghe.data['message'])
|
|
|
|
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
|