# -*- coding: utf-8 -*-
"""
(c) 2014-2017 - Copyright Red Hat Inc
Authors:
Pierre-Yves Chibon <pingou@pingoured.fr>
"""
from __future__ import unicode_literals, absolute_import
import subprocess
import sys
import traceback
import os
import six
import wtforms
from pagure.config import config as pagure_config
from pagure.exceptions import FileNotFoundException
import pagure.lib.query
import pagure.lib.git
from pagure.lib.git_auth import get_git_auth_helper
from pagure.lib.plugins import get_enabled_plugins
class RequiredIf(wtforms.validators.DataRequired):
""" Wtforms validator setting a field as required if another field
has a value.
"""
def __init__(self, fields, *args, **kwargs):
if isinstance(fields, six.string_types):
fields = [fields]
self.fields = fields
super(RequiredIf, self).__init__(*args, **kwargs)
def __call__(self, form, field):
for fieldname in self.fields:
nfield = form._fields.get(fieldname)
if nfield is None:
raise Exception('no field named "%s" in form' % fieldname)
if bool(nfield.data):
if (
not field.data
or isinstance(field.data, six.string_types)
and not field.data.strip()
):
if self.message is None:
message = field.gettext("This field is required.")
else:
message = self.message
field.errors[:] = []
raise wtforms.validators.StopValidation(message)
class BaseRunner(object):
dbobj = None
@classmethod
def runhook(
cls, session, username, hooktype, project, repotype, repodir, changes
):
""" Run a specific hook on a project.
By default, this calls out to the pre_receive, update or post_receive
functions as appropriate.
Args:
session (Session): Database session
username (string): The user performing a push
project (model.Project): The project this call is made for
repotype (string): Value of lib.query.get_repotypes() indicating
for which repo the current call is
repodir (string): Directory where a clone of the specified repo is
located. Do note that this might or might not be a writable
clone.
changes (dict): A dict with keys being the ref to update, values
being a tuple of (from, to).
For example: {'refs/heads/master': (hash_from, hash_to), ...}
"""
if hooktype == "pre-receive":
cls.pre_receive(
session=session,
username=username,
project=project,
repotype=repotype,
repodir=repodir,
changes=changes,
)
elif hooktype == "update":
cls.update(
session=session,
username=username,
project=project,
repotype=repotype,
repodir=repodir,
changes=changes,
)
elif hooktype == "post-receive":
cls.post_receive(
session=session,
username=username,
project=project,
repotype=repotype,
repodir=repodir,
changes=changes,
)
else:
raise ValueError('Invalid hook type "%s"' % hooktype)
@staticmethod
def pre_receive(session, username, project, repotype, repodir, changes):
""" Run the pre-receive tasks of a hook.
For args, see BaseRunner.runhook.
"""
pass
@staticmethod
def update(session, username, project, repotype, repodir, changes):
""" Run the update tasks of a hook.
For args, see BaseRunner.runhook.
Note that the "changes" list has exactly one element.
"""
pass
@staticmethod
def post_receive(session, username, project, repotype, repodir, changes):
""" Run the post-receive tasks of a hook.
For args, see BaseRunner.runhook.
"""
pass
class BaseHook(object):
""" Base class for pagure's hooks. """
name = None
form = None
description = None
backref = None
db_object = None
# hook_type is not used in hooks that use a Runner class, as those can
# implement run actions on whatever is useful to them.
hook_type = "post-receive"
runner = None
@classmethod
def set_up(cls, project):
""" Install the generic post-receive hook that allow us to call
multiple post-receive hooks as set per plugin.
"""
if project.is_on_repospanner:
# If the project is on repoSpanner, there's nothing to set up,
# as the hook script will be arranged by repo creation.
return
hook_files = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "files"
)
for repotype in pagure.lib.query.get_repotypes():
repopath = project.repopath(repotype)
if repopath is None:
continue
# Make sure the hooks folder exists
hookfolder = os.path.join(repopath, "hooks")
if not os.path.exists(hookfolder):
os.makedirs(hookfolder)
for hooktype in ("pre-receive", "update", "post-receive"):
# Install the main hook file
target = os.path.join(hookfolder, hooktype)
if not os.path.exists(target):
os.symlink(os.path.join(hook_files, "hookrunner"), target)
@classmethod
def base_install(cls, repopaths, dbobj, hook_name, filein):
""" Method called to install the hook for a project.
:arg project: a ``pagure.model.Project`` object to which the hook
should be installed
:arg dbobj: the DB object the hook uses to store the settings
information.
"""
if cls.runner:
# In the case of a new-style hook (with a Runner), there is no
# need to copy any files into place
return
for repopath in repopaths:
if not os.path.exists(repopath):
raise FileNotFoundException("Repo %s not found" % repopath)
hook_files = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "files"
)
# Make sure the hooks folder exists
hookfolder = os.path.join(repopath, "hooks")
if not os.path.exists(hookfolder):
os.makedirs(hookfolder)
# Install the hook itself
hook_file = os.path.join(
repopath, "hooks", cls.hook_type + "." + hook_name
)
if not os.path.exists(hook_file):
os.symlink(os.path.join(hook_files, filein), hook_file)
@classmethod
def base_remove(cls, repopaths, hook_name):
""" Method called to remove the hook of a project.
:arg project: a ``pagure.model.Project`` object to which the hook
should be installed
"""
for repopath in repopaths:
if not os.path.exists(repopath):
raise FileNotFoundException("Repo %s not found" % repopath)
hook_path = os.path.join(
repopath, "hooks", cls.hook_type + "." + hook_name
)
if os.path.exists(hook_path):
os.unlink(hook_path)
@classmethod
def install(cls, *args):
""" In sub-classess, this can be used for installation of the hook.
However, this is not required anymore for hooks with a Runner.
This class is here as backwards compatibility.
All args are ignored.
"""
if not cls.runner:
raise ValueError("BaseHook.install called for runner-less hook")
@classmethod
def remove(cls, *args):
""" In sub-classess, this can be used for removal of the hook.
However, this is not required anymore for hooks with a Runner.
This class is here as backwards compatibility.
All args are ignored.
"""
if not cls.runner:
raise ValueError("BaseHook.remove called for runner-less hook")
@classmethod
def is_enabled_for(cls, project):
""" Determine if this hook should be run for given project.
On some Pagure instances, some hooks should be run on all projects
that fulfill certain criteria. It is therefore not necessary to keep
database objects for them.
If a hook's backref is set to None, this method is run to determine
whether the hook should be run or not. These hooks also won't show
up on settings page, since they can't be turned off.
:arg project: The project to inspect
:type project: pagure.lib.model.Project
:return: True if this hook should be run on the given project,
False otherwise
"""
return False
def run_project_hooks(
session,
username,
project,
hooktype,
repotype,
repodir,
changes,
is_internal,
pull_request,
):
""" Function to run the hooks on a project
This will first call all the plugins with a Runner on the project,
and afterwards, for a non-repoSpanner repo, run all hooks/<hooktype>.*
scripts in the repo.
Args:
session: Database session
username (string): The user performing a push
project (model.Project): The project this call is made for
repotype (string): Value of lib.query.get_repotypes() indicating
for which repo the currnet call is
repodir (string): Directory where a clone of the specified repo is
located. Do note that this might or might not be a writable
clone.
hooktype (string): The type of hook to run: pre-receive, update
or post-receive
changes (dict): A dict with keys being the ref to update, values being
a tuple of (from, to).
is_internal (bool): Whether this push originated from Pagure internally
pull_request (model.PullRequest or None): The pull request whose merge
is initiating this hook run.
"""
debug = pagure_config.get("HOOK_DEBUG", False)
# First we run dynamic ACLs
authbackend = get_git_auth_helper()
if (
is_internal
and username == "pagure"
and repotype in ("tickets", "requests")
):
if debug:
print("This is an internal push, dynamic ACL is pre-approved")
elif not authbackend.is_dynamic:
if debug:
print("Auth backend %s is static-only" % authbackend)
elif hooktype == "post-receive":
if debug:
print("Skipping auth backend during post-receive")
else:
if debug:
print(
"Checking push request against auth backend %s" % authbackend
)
todeny = []
for refname in changes:
change = changes[refname]
authresult = authbackend.check_acl(
session,
project,
username,
refname,
is_update=hooktype == "update",
revfrom=change[0],
revto=change[1],
is_internal=is_internal,
pull_request=pull_request,
repotype=repotype,
repodir=repodir,
)
if debug:
print(
"Auth result for ref %s: %s"
% (refname, "Accepted" if authresult else "Denied")
)
if not authresult:
print(
"Denied push for ref '%s' for user '%s'"
% (refname, username)
)
todeny.append(refname)
for toremove in todeny:
del changes[toremove]
if not changes:
print("All changes have been rejected")
sys.exit(1)
# Now we run the hooks for plugins
haderrors = False
for plugin, _ in get_enabled_plugins(project):
if not plugin.runner:
if debug:
print(
"Hook plugin %s should be ported to Runner" % plugin.name
)
else:
if debug:
print("Running plugin %s" % plugin.name)
try:
plugin.runner.runhook(
session=session,
username=username,
hooktype=hooktype,
project=project,
repotype=repotype,
repodir=repodir,
changes=changes,
)
except Exception as e:
if hooktype != "pre-receive" or debug:
traceback.print_exc()
else:
print(str(e))
haderrors = True
if project.is_on_repospanner:
# We are done. We are not doing any legacy hooks for repoSpanner
return
hookdir = os.path.join(repodir, "hooks")
if not os.path.exists(hookdir):
return
stdin = ""
args = []
if hooktype == "update":
refname = six.next(six.iterkeys(changes))
(revfrom, revto) = changes[refname]
args = [refname, revfrom, revto]
else:
stdin = (
"\n".join(
[
"%s %s %s" % (changes[refname] + (refname,))
for refname in changes
]
)
+ "\n"
)
stdin = stdin.encode("utf-8")
if debug:
print(
"Running legacy hooks (if any) with args: %s, stdin: %s"
% (args, stdin)
)
for hook in os.listdir(hookdir):
# This is for legacy hooks, which create symlinks in the form of
# "post-receive.$pluginname"
if hook.startswith(hooktype + "."):
hookfile = os.path.join(hookdir, hook)
# By-pass all the old hooks that pagure may have created before
# moving to the runner architecture
if hook in pagure.lib.query.ORIGINAL_PAGURE_HOOK:
continue
if hook.endswith(".sample"):
# Ignore the samples that Git inserts
continue
# Execute
print(
"Running legacy hook %s. "
"Please ask your admin to port this to the new plugin "
"format, as the current system will cease functioning "
"in a future Pagure release" % hook
)
# Using subprocess.Popen rather than check_call so that stdin
# can be passed without having to use a temporary file.
proc = subprocess.Popen(
[hookfile] + args, cwd=repodir, stdin=subprocess.PIPE
)
proc.communicate(stdin)
ecode = proc.wait()
if ecode != 0:
print("Hook %s errored out" % hook)
haderrors = True
if haderrors:
session.close()
raise SystemExit(1)
def extract_changes(from_stdin):
""" Extracts a changes dict from either stdin or argv
Args:
from_stdin (bool): Whether to use stdin. If false, uses argv
"""
changes = {}
if from_stdin:
for line in sys.stdin:
(oldrev, newrev, refname) = str(line).strip().split(str(" "), 2)
if six.PY2:
refname = refname.decode("utf-8")
changes[refname] = (oldrev, newrev)
else:
(refname, oldrev, newrev) = sys.argv[1:]
if six.PY2:
refname = refname.decode("utf-8")
changes[refname] = (oldrev, newrev)
return changes
def run_hook_file(hooktype):
""" Runs a specific hook by grabbing the changes and running functions.
Args:
hooktype (string): The name of the hook to run: pre-receive, update
or post-receive
"""
if hooktype not in ("pre-receive", "update", "post-receive"):
raise ValueError("Hook type %s not valid" % hooktype)
changes = extract_changes(from_stdin=hooktype != "update")
session = pagure.lib.model_base.create_session(pagure_config["DB_URL"])
if not session:
raise Exception("Unable to initialize db session")
pushuser = os.environ.get("GL_USER")
is_internal = os.environ.get("internal", False) == "yes"
pull_request = None
if "pull_request_uid" in os.environ:
pull_request = pagure.lib.query.get_request_by_uid(
session, os.environ["pull_request_uid"]
)
if pagure_config.get("HOOK_DEBUG", False):
print("Changes: %s" % changes)
gitdir = os.path.abspath(os.environ["GIT_DIR"])
(
repotype,
username,
namespace,
repo,
) = pagure.lib.git.get_repo_info_from_path(gitdir)
project = pagure.lib.query._get_project(
session, repo, user=username, namespace=namespace
)
if not project:
raise Exception(
"Not able to find the project corresponding to: %%s - s - "
"%s - %s" % (repotype, username, namespace, repo)
)
if pagure_config.get("HOOK_DEBUG", False):
print("Running %s hooks for %s" % (hooktype, project.fullname))
run_project_hooks(
session,
pushuser,
project,
hooktype,
repotype,
gitdir,
changes,
is_internal,
pull_request,
)
session.close()