__init__.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2014-2017 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import absolute_import, unicode_literals
  8. import os
  9. import subprocess
  10. import sys
  11. import traceback
  12. import six
  13. import wtforms
  14. import pagure.lib.git
  15. import pagure.lib.query
  16. from pagure.config import config as pagure_config
  17. from pagure.exceptions import FileNotFoundException
  18. from pagure.lib.git_auth import get_git_auth_helper
  19. from pagure.lib.plugins import get_enabled_plugins
  20. class RequiredIf(wtforms.validators.DataRequired):
  21. """Wtforms validator setting a field as required if another field
  22. has a value.
  23. """
  24. def __init__(self, fields, *args, **kwargs):
  25. if isinstance(fields, six.string_types):
  26. fields = [fields]
  27. self.fields = fields
  28. super(RequiredIf, self).__init__(*args, **kwargs)
  29. def __call__(self, form, field):
  30. for fieldname in self.fields:
  31. nfield = form._fields.get(fieldname)
  32. if nfield is None:
  33. raise Exception('no field named "%s" in form' % fieldname)
  34. if bool(nfield.data):
  35. if (
  36. not field.data
  37. or isinstance(field.data, six.string_types)
  38. and not field.data.strip()
  39. ):
  40. if self.message is None:
  41. message = field.gettext("This field is required.")
  42. else:
  43. message = self.message
  44. field.errors[:] = []
  45. raise wtforms.validators.StopValidation(message)
  46. class BaseRunner(object):
  47. dbobj = None
  48. @classmethod
  49. def runhook(
  50. cls, session, username, hooktype, project, repotype, repodir, changes
  51. ):
  52. """Run a specific hook on a project.
  53. By default, this calls out to the pre_receive, update or post_receive
  54. functions as appropriate.
  55. Args:
  56. session (Session): Database session
  57. username (string): The user performing a push
  58. project (model.Project): The project this call is made for
  59. repotype (string): Value of lib.query.get_repotypes() indicating
  60. for which repo the current call is
  61. repodir (string): Directory where a clone of the specified repo is
  62. located. Do note that this might or might not be a writable
  63. clone.
  64. changes (dict): A dict with keys being the ref to update, values
  65. being a tuple of (from, to).
  66. For example: {'refs/heads/master': (hash_from, hash_to), ...}
  67. """
  68. if hooktype == "pre-receive":
  69. cls.pre_receive(
  70. session=session,
  71. username=username,
  72. project=project,
  73. repotype=repotype,
  74. repodir=repodir,
  75. changes=changes,
  76. )
  77. elif hooktype == "update":
  78. cls.update(
  79. session=session,
  80. username=username,
  81. project=project,
  82. repotype=repotype,
  83. repodir=repodir,
  84. changes=changes,
  85. )
  86. elif hooktype == "post-receive":
  87. cls.post_receive(
  88. session=session,
  89. username=username,
  90. project=project,
  91. repotype=repotype,
  92. repodir=repodir,
  93. changes=changes,
  94. )
  95. else:
  96. raise ValueError('Invalid hook type "%s"' % hooktype)
  97. @staticmethod
  98. def pre_receive(session, username, project, repotype, repodir, changes):
  99. """Run the pre-receive tasks of a hook.
  100. For args, see BaseRunner.runhook.
  101. """
  102. pass
  103. @staticmethod
  104. def update(session, username, project, repotype, repodir, changes):
  105. """Run the update tasks of a hook.
  106. For args, see BaseRunner.runhook.
  107. Note that the "changes" list has exactly one element.
  108. """
  109. pass
  110. @staticmethod
  111. def post_receive(session, username, project, repotype, repodir, changes):
  112. """Run the post-receive tasks of a hook.
  113. For args, see BaseRunner.runhook.
  114. """
  115. pass
  116. class BaseHook(object):
  117. """Base class for pagure's hooks."""
  118. name = None
  119. form = None
  120. description = None
  121. backref = None
  122. db_object = None
  123. # hook_type is not used in hooks that use a Runner class, as those can
  124. # implement run actions on whatever is useful to them.
  125. hook_type = "post-receive"
  126. runner = None
  127. @classmethod
  128. def set_up(cls, project):
  129. """Install the generic post-receive hook that allow us to call
  130. multiple post-receive hooks as set per plugin.
  131. """
  132. if project.is_on_repospanner:
  133. # If the project is on repoSpanner, there's nothing to set up,
  134. # as the hook script will be arranged by repo creation.
  135. return
  136. hook_files = os.path.join(
  137. os.path.dirname(os.path.realpath(__file__)), "files"
  138. )
  139. for repotype in pagure.lib.query.get_repotypes():
  140. repopath = project.repopath(repotype)
  141. if repopath is None:
  142. continue
  143. # Make sure the hooks folder exists
  144. hookfolder = os.path.join(repopath, "hooks")
  145. if not os.path.exists(hookfolder):
  146. os.makedirs(hookfolder)
  147. for hooktype in ("pre-receive", "update", "post-receive"):
  148. # Install the main hook file
  149. target = os.path.join(hookfolder, hooktype)
  150. if not os.path.exists(target):
  151. if os.path.islink(target):
  152. os.unlink(target)
  153. os.symlink(os.path.join(hook_files, "hookrunner"), target)
  154. @classmethod
  155. def base_install(cls, repopaths, dbobj, hook_name, filein):
  156. """Method called to install the hook for a project.
  157. :arg project: a ``pagure.model.Project`` object to which the hook
  158. should be installed
  159. :arg dbobj: the DB object the hook uses to store the settings
  160. information.
  161. """
  162. if cls.runner:
  163. # In the case of a new-style hook (with a Runner), there is no
  164. # need to copy any files into place
  165. return
  166. for repopath in repopaths:
  167. if not os.path.exists(repopath):
  168. raise FileNotFoundException("Repo %s not found" % repopath)
  169. hook_files = os.path.join(
  170. os.path.dirname(os.path.realpath(__file__)), "files"
  171. )
  172. # Make sure the hooks folder exists
  173. hookfolder = os.path.join(repopath, "hooks")
  174. if not os.path.exists(hookfolder):
  175. os.makedirs(hookfolder)
  176. # Install the hook itself
  177. hook_file = os.path.join(
  178. repopath, "hooks", cls.hook_type + "." + hook_name
  179. )
  180. if not os.path.exists(hook_file):
  181. os.symlink(os.path.join(hook_files, filein), hook_file)
  182. @classmethod
  183. def base_remove(cls, repopaths, hook_name):
  184. """Method called to remove the hook of a project.
  185. :arg project: a ``pagure.model.Project`` object to which the hook
  186. should be installed
  187. """
  188. for repopath in repopaths:
  189. if not os.path.exists(repopath):
  190. raise FileNotFoundException("Repo %s not found" % repopath)
  191. hook_path = os.path.join(
  192. repopath, "hooks", cls.hook_type + "." + hook_name
  193. )
  194. if os.path.exists(hook_path):
  195. os.unlink(hook_path)
  196. @classmethod
  197. def install(cls, *args):
  198. """In sub-classess, this can be used for installation of the hook.
  199. However, this is not required anymore for hooks with a Runner.
  200. This class is here as backwards compatibility.
  201. All args are ignored.
  202. """
  203. if not cls.runner:
  204. raise ValueError("BaseHook.install called for runner-less hook")
  205. @classmethod
  206. def remove(cls, *args):
  207. """In sub-classess, this can be used for removal of the hook.
  208. However, this is not required anymore for hooks with a Runner.
  209. This class is here as backwards compatibility.
  210. All args are ignored.
  211. """
  212. if not cls.runner:
  213. raise ValueError("BaseHook.remove called for runner-less hook")
  214. @classmethod
  215. def is_enabled_for(cls, project):
  216. """Determine if this hook should be run for given project.
  217. On some Pagure instances, some hooks should be run on all projects
  218. that fulfill certain criteria. It is therefore not necessary to keep
  219. database objects for them.
  220. If a hook's backref is set to None, this method is run to determine
  221. whether the hook should be run or not. These hooks also won't show
  222. up on settings page, since they can't be turned off.
  223. :arg project: The project to inspect
  224. :type project: pagure.lib.model.Project
  225. :return: True if this hook should be run on the given project,
  226. False otherwise
  227. """
  228. return False
  229. def run_project_hooks(
  230. session,
  231. username,
  232. project,
  233. hooktype,
  234. repotype,
  235. repodir,
  236. changes,
  237. is_internal,
  238. pull_request,
  239. ):
  240. """Function to run the hooks on a project
  241. This will first call all the plugins with a Runner on the project,
  242. and afterwards, for a non-repoSpanner repo, run all hooks/<hooktype>.*
  243. scripts in the repo.
  244. Args:
  245. session: Database session
  246. username (string): The user performing a push
  247. project (model.Project): The project this call is made for
  248. repotype (string): Value of lib.query.get_repotypes() indicating
  249. for which repo the currnet call is
  250. repodir (string): Directory where a clone of the specified repo is
  251. located. Do note that this might or might not be a writable
  252. clone.
  253. hooktype (string): The type of hook to run: pre-receive, update
  254. or post-receive
  255. changes (dict): A dict with keys being the ref to update, values being
  256. a tuple of (from, to).
  257. is_internal (bool): Whether this push originated from Pagure internally
  258. pull_request (model.PullRequest or None): The pull request whose merge
  259. is initiating this hook run.
  260. """
  261. debug = pagure_config.get("HOOK_DEBUG", False)
  262. # First we run dynamic ACLs
  263. authbackend = get_git_auth_helper()
  264. if is_internal and username == "pagure":
  265. if debug:
  266. print("This is an internal push, dynamic ACL is pre-approved")
  267. elif not authbackend.is_dynamic:
  268. if debug:
  269. print("Auth backend %s is static-only" % authbackend)
  270. elif hooktype == "post-receive":
  271. if debug:
  272. print("Skipping auth backend during post-receive")
  273. else:
  274. if debug:
  275. print(
  276. "Checking push request against auth backend %s" % authbackend
  277. )
  278. todeny = []
  279. for refname in changes:
  280. change = changes[refname]
  281. authresult = authbackend.check_acl(
  282. session,
  283. project,
  284. username,
  285. refname,
  286. is_update=hooktype == "update",
  287. revfrom=change[0],
  288. revto=change[1],
  289. is_internal=is_internal,
  290. pull_request=pull_request,
  291. repotype=repotype,
  292. repodir=repodir,
  293. )
  294. if debug:
  295. print(
  296. "Auth result for ref %s: %s"
  297. % (refname, "Accepted" if authresult else "Denied")
  298. )
  299. if not authresult:
  300. print(
  301. "Denied push for ref '%s' for user '%s'"
  302. % (refname, username)
  303. )
  304. todeny.append(refname)
  305. for toremove in todeny:
  306. del changes[toremove]
  307. if not changes:
  308. print("All changes have been rejected")
  309. sys.exit(1)
  310. # Now we run the hooks for plugins
  311. haderrors = False
  312. for plugin, _ in get_enabled_plugins(project):
  313. if not plugin.runner:
  314. if debug:
  315. print(
  316. "Hook plugin %s should be ported to Runner" % plugin.name
  317. )
  318. else:
  319. if debug:
  320. print("Running plugin %s" % plugin.name)
  321. try:
  322. plugin.runner.runhook(
  323. session=session,
  324. username=username,
  325. hooktype=hooktype,
  326. project=project,
  327. repotype=repotype,
  328. repodir=repodir,
  329. changes=changes,
  330. )
  331. except Exception as e:
  332. if hooktype != "pre-receive" or debug:
  333. traceback.print_exc()
  334. else:
  335. print(str(e))
  336. haderrors = True
  337. if project.is_on_repospanner:
  338. # We are done. We are not doing any legacy hooks for repoSpanner
  339. return
  340. hookdir = os.path.join(repodir, "hooks")
  341. if not os.path.exists(hookdir):
  342. return
  343. stdin = ""
  344. args = []
  345. if hooktype == "update":
  346. refname = six.next(six.iterkeys(changes))
  347. (revfrom, revto) = changes[refname]
  348. args = [refname, revfrom, revto]
  349. else:
  350. stdin = (
  351. "\n".join(
  352. [
  353. "%s %s %s" % (changes[refname] + (refname,))
  354. for refname in changes
  355. ]
  356. )
  357. + "\n"
  358. )
  359. stdin = stdin.encode("utf-8")
  360. if debug:
  361. print(
  362. "Running legacy hooks (if any) with args: %s, stdin: %s"
  363. % (args, stdin)
  364. )
  365. for hook in os.listdir(hookdir):
  366. # This is for legacy hooks, which create symlinks in the form of
  367. # "post-receive.$pluginname"
  368. if hook.startswith(hooktype + "."):
  369. hookfile = os.path.join(hookdir, hook)
  370. # By-pass all the old hooks that pagure may have created before
  371. # moving to the runner architecture
  372. if hook in pagure.lib.query.ORIGINAL_PAGURE_HOOK:
  373. continue
  374. if hook.endswith(".sample"):
  375. # Ignore the samples that Git inserts
  376. continue
  377. # Execute
  378. print(
  379. "Running legacy hook %s. "
  380. "Please ask your admin to port this to the new plugin "
  381. "format, as the current system will cease functioning "
  382. "in a future Pagure release" % hook
  383. )
  384. # Using subprocess.Popen rather than check_call so that stdin
  385. # can be passed without having to use a temporary file.
  386. proc = subprocess.Popen(
  387. [hookfile] + args, cwd=repodir, stdin=subprocess.PIPE
  388. )
  389. proc.communicate(stdin)
  390. ecode = proc.wait()
  391. if ecode != 0:
  392. print("Hook %s errored out" % hook)
  393. haderrors = True
  394. if haderrors:
  395. session.close()
  396. raise SystemExit(1)
  397. def extract_changes(from_stdin):
  398. """Extracts a changes dict from either stdin or argv
  399. Args:
  400. from_stdin (bool): Whether to use stdin. If false, uses argv
  401. """
  402. changes = {}
  403. if from_stdin:
  404. for line in sys.stdin:
  405. (oldrev, newrev, refname) = str(line).strip().split(str(" "), 2)
  406. if six.PY2:
  407. refname = refname.decode("utf-8")
  408. changes[refname] = (oldrev, newrev)
  409. else:
  410. (refname, oldrev, newrev) = sys.argv[1:]
  411. if six.PY2:
  412. refname = refname.decode("utf-8")
  413. changes[refname] = (oldrev, newrev)
  414. return changes
  415. def run_hook_file(hooktype):
  416. """Runs a specific hook by grabbing the changes and running functions.
  417. Args:
  418. hooktype (string): The name of the hook to run: pre-receive, update
  419. or post-receive
  420. """
  421. if pagure_config.get("NOGITHOOKS") or False:
  422. return
  423. if hooktype not in ("pre-receive", "update", "post-receive"):
  424. raise ValueError("Hook type %s not valid" % hooktype)
  425. changes = extract_changes(from_stdin=hooktype != "update")
  426. session = pagure.lib.model_base.create_session(pagure_config["DB_URL"])
  427. if not session:
  428. raise Exception("Unable to initialize db session")
  429. pushuser = os.environ.get("GL_USER")
  430. is_internal = os.environ.get("internal", False) == "yes"
  431. pull_request = None
  432. if "pull_request_uid" in os.environ:
  433. pull_request = pagure.lib.query.get_request_by_uid(
  434. session, os.environ["pull_request_uid"]
  435. )
  436. if pagure_config.get("HOOK_DEBUG", False):
  437. print("Changes: %s" % changes)
  438. gitdir = os.path.abspath(os.environ["GIT_DIR"])
  439. (
  440. repotype,
  441. username,
  442. namespace,
  443. repo,
  444. ) = pagure.lib.git.get_repo_info_from_path(gitdir)
  445. project = pagure.lib.query._get_project(
  446. session, repo, user=username, namespace=namespace
  447. )
  448. if not project:
  449. raise Exception(
  450. "Not able to find the project corresponding to: %s - %s - "
  451. "%s - %s" % (repotype, username, namespace, repo)
  452. )
  453. if pagure_config.get("HOOK_DEBUG", False):
  454. print("Running %s hooks for %s" % (hooktype, project.fullname))
  455. run_project_hooks(
  456. session,
  457. pushuser,
  458. project,
  459. hooktype,
  460. repotype,
  461. gitdir,
  462. changes,
  463. is_internal,
  464. pull_request,
  465. )
  466. session.close()