frank.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. """
  2. Frank-ci - A simple CI for Pythonistas, based on Flask + Huey.
  3. For more information see the docs/frank.rst
  4. """
  5. import hmac
  6. import os
  7. import subprocess
  8. import subprocess as sp
  9. import logging
  10. import hashlib
  11. import yaml
  12. import click
  13. from flask import Flask, request, abort
  14. import conf
  15. from shell import Shell
  16. from collections import OrderedDict, namedtuple
  17. import importlib
  18. from conf import taskq
  19. import types
  20. import pickle
  21. # monkey patch data store
  22. _list = "select * FROM {0}"
  23. def list_results(obj):
  24. with obj._db.get_connection() as conn:
  25. try:
  26. return list(conn.execute(obj._list.format(obj.name)))
  27. except:
  28. return None
  29. taskq.result_store._list = _list
  30. taskq.result_store.list_results = types.MethodType(list_results,
  31. taskq.result_store)
  32. PythonCode = namedtuple('PythonCode', ['path', 'args', 'kwargs', 'code'])
  33. def override_run(self, command, **kwargs):
  34. """
  35. Override Shell.run to handle exceptions and accept kwargs
  36. that Popen accepts
  37. """
  38. self.last_command = command
  39. command_bits = self._split_command(command)
  40. _kwargs = {
  41. 'stdout': subprocess.PIPE,
  42. 'stderr': subprocess.PIPE,
  43. 'universal_newlines': True,
  44. }
  45. if kwargs:
  46. for kw in kwargs:
  47. _kwargs[kw] = kwargs[kw]
  48. _kwargs['shell'] = True
  49. if self.has_input:
  50. _kwargs['stdin'] = subprocess.PIPE
  51. try:
  52. self._popen = subprocess.Popen(
  53. command_bits,
  54. **_kwargs
  55. )
  56. except Exception as E:
  57. self.exception = E
  58. return self
  59. self.pid = self._popen.pid
  60. if not self.has_input:
  61. self._communicate()
  62. return self
  63. Shell.run = override_run
  64. def ordered_load(stream, Loader=yaml.Loader, selfect_pairs_hook=OrderedDict):
  65. class OrderedLoader(Loader):
  66. pass
  67. def construct_mapping(loader, node):
  68. loader.flatten_mapping(node)
  69. return selfect_pairs_hook(loader.construct_pairs(node))
  70. OrderedLoader.add_constructor(
  71. yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
  72. construct_mapping)
  73. return yaml.load(stream, OrderedLoader)
  74. app = Flask(__name__)
  75. app.config.from_object(conf)
  76. def parse_branch_gh(request_json):
  77. """
  78. parse the branch to clone from a github payload
  79. "ref": "refs/heads/develop", -> should return develop
  80. """
  81. return request_json['ref'].split('/')[-1]
  82. def parse_yaml(clone_dest):
  83. os.chdir(clone_dest)
  84. if os.path.exists('.frank.yaml'):
  85. with open('.frank.yaml') as f:
  86. y = ordered_load(f, yaml.SafeLoader)
  87. return y
  88. def load_actions(yaml):
  89. pass
  90. def report_success(results):
  91. pass
  92. def report_failure(results):
  93. pass
  94. def run_action(axn):
  95. results = []
  96. # run shell or python callable object without arguments
  97. if isinstance(axn, list):
  98. if axn[0] == 'shell':
  99. for cmd in axn[1:]:
  100. sh = Shell()
  101. assert isinstance(cmd, str)
  102. sh.run(cmd)
  103. results.append(sh)
  104. if sh.code:
  105. break
  106. if axn[0] == 'python':
  107. for func in axn[1:]:
  108. mod, f = func.split(':')
  109. mod = importlib.import_module(mod)
  110. f = getattr(mod, f)
  111. res = f()
  112. results.append(PythonCode(func, None, None, res))
  113. # run shell or python callable object arguments
  114. elif isinstance(axn, OrderedDict):
  115. if 'shell' in axn:
  116. sh = Shell()
  117. cmd = axn['shell'].pop('cmd')
  118. assert isinstance(cmd, str)
  119. kwargs = axn['shell']
  120. sh.run(cmd, **kwargs)
  121. results.append(sh)
  122. if 'python' in axn:
  123. callables = axn['python']
  124. for func in callables:
  125. mod, f = func.split(':')
  126. mod = importlib.import_module(mod)
  127. try:
  128. f = getattr(mod, f)
  129. res = f()
  130. except AttributeError as E:
  131. res = E
  132. results.append(PythonCode(func, None, None, res))
  133. return results
  134. def clone(clone_url, branch, depth=1):
  135. cmd = ('git clone --depth={d} -b {branch} --single-branch '
  136. '{git_url} {dir}'.format(d=depth, branch=branch,
  137. git_url=clone_url, dir=branch))
  138. pull = sp.Popen(cmd, stderr=sp.STDOUT, shell=True)
  139. out, err = pull.communicate()
  140. return out, err
  141. @taskq.task()
  142. def count_beans():
  143. print "count_12"
  144. return "count_12"
  145. @taskq.task()
  146. def build_task(request_json):
  147. """
  148. . clone the latest commit (--depth 1)
  149. . parse yaml config
  150. . for each command in the config
  151. . run command
  152. . report success or failure
  153. """
  154. clone_dest = parse_branch_gh(request_json)
  155. repo_name = request_as_json["repository"]['name']
  156. try:
  157. o, e = clone(request_as_json['repository']['ssh_url'], clone_dest)
  158. except Exception as E:
  159. print E, E.message
  160. # parse yaml is still very crude ...
  161. # it could yield selfect with a run method
  162. # thus:
  163. # for action in parse_yaml(clone_dest):
  164. # action.run()
  165. #
  166. # this should also handle dependencies,
  167. # the following implementation is very crud
  168. failed = None
  169. for action in parse_yaml(clone_dest):
  170. # if config says we use huey, we should modiy run_action
  171. results = run_action(action)
  172. if any([result.code for result in results]):
  173. report_failure(results)
  174. else:
  175. report_success(results)
  176. @app.route('/beans')
  177. def do_beans():
  178. a = count_beans()
  179. ans = a.get(blocking=True)
  180. return ans
  181. @app.route('/results')
  182. def show_resutls():
  183. """
  184. TODO: Make a nicer web page with jinja2
  185. """
  186. res = {r[1]:r[2] for r in taskq.result_store.list_results()}
  187. return ''.join(['<p>'+str(k) + ': ' + str(pickle.loads(v)) + '</p>\n' for
  188. (k, v) in res.iteritems()])
  189. @app.route('/', methods=['POST'])
  190. def start():
  191. """
  192. main logic:
  193. 1. listen to post
  194. 2. if authenticated post do:
  195. enqueue task to build or test the code
  196. # This is authentication for github only
  197. # We could\should check for other hostings
  198. """
  199. ans = hmac.new(app.config['POST_KEY'], request.data,
  200. hashlib.sha1).hexdigest()
  201. secret = request.headers[app.config['SECRET_KEY_NAME']].split('=')[-1]
  202. if ans != secret:
  203. return abort(500)
  204. request_as_json = request.get_json()
  205. build_task(request_as_json)
  206. return "OK"
  207. @app.route('/status')
  208. def status():
  209. return "Frank is alive\n"
  210. @click.group()
  211. def cli():
  212. pass
  213. @cli.command('web', short_help='start the web service')
  214. @click.option('--port','-p', default=8080)
  215. @click.option('--debug', default=False, is_flag=True)
  216. def web(port, debug):
  217. click.echo("DEBUG: %s" % debug)
  218. app.run(host='0.0.0.0',port=port, debug=debug)
  219. @cli.command(context_settings=dict(
  220. ignore_unknown_options=True,
  221. allow_extra_args=True))
  222. @click.argument('worker_args', nargs=-1, type=click.UNPROCESSED)
  223. def worker(worker_args, short_help='start the consumer of tasks'):
  224. from huey.bin.huey_consumer import (get_option_parser, Consumer,
  225. setup_logger, RotatingFileHandler)
  226. from conf import taskq
  227. parser = get_option_parser()
  228. opts, args = parser.parse_args(list(worker_args))
  229. setup_logger(logging.INFO, opts.logfile)
  230. consumer = Consumer(taskq, 2, opts.periodic, opts.initial_delay,
  231. opts.backoff, opts.max_delay, opts.utc,
  232. opts.scheduler_interval, opts.periodic_task_interval)
  233. consumer.run()