mirror of
https://github.com/quay/quay.git
synced 2025-04-19 21:42:17 +03:00
This is a required change, as GitHub is deprecating the query parameters in a few months. See https://developer.github.com/changes/2020-02-10-deprecating-auth-through-query-param/ for details. Fixes https://issues.redhat.com/browse/PROJQUAY-268
594 lines
21 KiB
Python
594 lines
21 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(
|
|
base_url=github_trigger.api_endpoint(),
|
|
login_or_token=self.auth_token if self.auth_token else github_trigger.client_id(),
|
|
password=None if self.auth_token else 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
|