# -*- coding: utf-8 -*-
"""
(c) 2014-2017 - Copyright Red Hat Inc
Authors:
Pierre-Yves Chibon <pingou@pingoured.fr>
"""
from __future__ import unicode_literals
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
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.Required):
""" 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.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
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.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")
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.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)
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, with_default=True):
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:
traceback.print_exc()
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 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)
# Determine if this is an actual hook, or if it's a remnant
# from a hook that was installed before it was moved to the
# runner system.
if os.path.realpath(hookfile) == pagure.lib.HOOK_DNE_TARGET:
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:
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) = line.strip().split(" ", 2)
changes[refname] = (oldrev, newrev)
else:
(refname, oldrev, newrev) = sys.argv[1:]
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.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.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._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)
)
print("Running hooks for %s" % project.fullname)
run_project_hooks(
session,
pushuser,
project,
hooktype,
repotype,
gitdir,
changes,
is_internal,
pull_request,
)
session.close()