frank.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. """
  2. Frank can be configured using the following yaml::
  3. .frank.yml
  4. commads:
  5. - test
  6. - deploy
  7. - publish
  8. newtag:
  9. - python:
  10. - frank.actions:detect_new_tag
  11. deploy:
  12. - cmd
  13. - runif:
  14. - test
  15. - newtag
  16. publish:
  17. - shell:
  18. - cd docs; make html
  19. - python:
  20. - frank.actions:recursive_copy
  21. The sections commands is a simple list of command you would
  22. like to define.
  23. This section is optional. It's main purpose is for you to decalre
  24. which commands frank should execute upon recieving a load.
  25. Each command is a dictionary with the following possible keys::
  26. cmd_name:
  27. - cmd # not mandatory if you include it in the list of commands
  28. - runif # a conditional to determine whether to run or skip the command
  29. # it can contain multiple directives which can be python
  30. # code to execute
  31. # or shell code
  32. For example, let's say you would like to build the sphinx documentation
  33. of your project after every push. You start by defining a command
  34. build_docs in the following way::
  35. build_docs:
  36. - cmd
  37. - shell:
  38. - cd docs
  39. - make html
  40. You could also specify::
  41. build_docs:
  42. - cmd
  43. - shell: cd docs; make html
  44. or::
  45. build_docs:
  46. - shell:
  47. - cwd: docs
  48. - cmd: make html
  49. This tells that `build_docs` is a command that executes after every push.
  50. It will execute a shell and will run the shell commands
  51. `cd docs; make html`. Pretty straight forward!
  52. Now, let's refine. Suppose we want to build the docs only if the docs changed,
  53. thus ignoring completely, changes in the embeded docs strings*::
  54. build_docs:
  55. - cmd
  56. - runif:
  57. - shell:
  58. - git --no-pager diff --name-status HEAD~1 | grep -v docs
  59. - shell: cd docs; make html
  60. Now, the command will run if the latest git commit have changed the docs
  61. directory. Since the grep command will return exit with 1.
  62. Alternatively, the conditional to run can be some python code that returns
  63. True or False. Here is how to specify this:
  64. build_docs:
  65. - cmd
  66. - runif:
  67. - python: frank.githubparsers:detect_changes_in_docs
  68. - shell: cd docs; make html
  69. This, will invoke the method ``detect_changes_in_docs`` which is
  70. found in the module `frank.githubparsers`.
  71. If this function will return True the shell command will execute.
  72. If the methods takes some arguments, they could also be given.
  73. Suppose the function is called `detect_changes_in` and this function
  74. takes a single paramter called path we can configure the
  75. build_docs command in the following way:
  76. build_docs:
  77. - cmd
  78. - runif:
  79. - python:
  80. - function: frank.githubparsers:detect_changes_in
  81. - args:
  82. - path: docs
  83. * This is probably a lame idea, but it's good for demonstration.
  84. So, do write doc-strings, and do build your docs often.
  85. """
  86. import hmac
  87. import os
  88. import subprocess
  89. import subprocess as sp
  90. import logging
  91. import hashlib
  92. import yaml
  93. import click
  94. from flask import Flask, request, abort
  95. import conf
  96. from shell import Shell
  97. from collections import OrderedDict, namedtuple
  98. import importlib
  99. from conf import taskq
  100. import types
  101. # monkey patch data store
  102. _list = "select * FROM {0}"
  103. def list_results(obj):
  104. with obj._db.get_connection() as conn:
  105. try:
  106. return list(conn.execute(obj._list.format(obj.name)))
  107. except:
  108. return None
  109. taskq.result_store._list = _list
  110. taskq.result_store.list_results = types.MethodType(list_results,
  111. taskq.result_store)
  112. PythonCode = namedtuple('PythonCode', ['path', 'args', 'kwargs', 'code'])
  113. def override_run(self, command, **kwargs):
  114. """
  115. Override Shell.run to handle exceptions and accept kwargs
  116. that Popen accepts
  117. """
  118. self.last_command = command
  119. command_bits = self._split_command(command)
  120. _kwargs = {
  121. 'stdout': subprocess.PIPE,
  122. 'stderr': subprocess.PIPE,
  123. 'universal_newlines': True,
  124. }
  125. if kwargs:
  126. for kw in kwargs:
  127. _kwargs[kw] = kwargs[kw]
  128. _kwargs['shell'] = True
  129. if self.has_input:
  130. _kwargs['stdin'] = subprocess.PIPE
  131. try:
  132. self._popen = subprocess.Popen(
  133. command_bits,
  134. **_kwargs
  135. )
  136. except Exception as E:
  137. self.exception = E
  138. return self
  139. self.pid = self._popen.pid
  140. if not self.has_input:
  141. self._communicate()
  142. return self
  143. Shell.run = override_run
  144. def ordered_load(stream, Loader=yaml.Loader, selfect_pairs_hook=OrderedDict):
  145. class OrderedLoader(Loader):
  146. pass
  147. def construct_mapping(loader, node):
  148. loader.flatten_mapping(node)
  149. return selfect_pairs_hook(loader.construct_pairs(node))
  150. OrderedLoader.add_constructor(
  151. yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
  152. construct_mapping)
  153. return yaml.load(stream, OrderedLoader)
  154. app = Flask(__name__)
  155. app.config.from_object(conf)
  156. def parse_branch_gh(request_json):
  157. """
  158. parse the branch to clone from a github payload
  159. "ref": "refs/heads/develop", -> should return develop
  160. """
  161. return request_json['ref'].split('/')[-1]
  162. def parse_yaml(clone_dest):
  163. os.chdir(clone_dest)
  164. if os.path.exists('.frank.yaml'):
  165. with open('.frank.yaml') as f:
  166. y = ordered_load(f, yaml.SafeLoader)
  167. return y
  168. def load_actions(yaml):
  169. pass
  170. def report_success(results):
  171. pass
  172. def report_failure(results):
  173. pass
  174. def run_action(axn):
  175. results = []
  176. # run shell or python callable object without arguments
  177. if isinstance(axn, list):
  178. if axn[0] == 'shell':
  179. for cmd in axn[1:]:
  180. sh = Shell()
  181. assert isinstance(cmd, str)
  182. sh.run(cmd)
  183. results.append(sh)
  184. if sh.code:
  185. break
  186. if axn[0] == 'python':
  187. for func in axn[1:]:
  188. mod, f = func.split(':')
  189. mod = importlib.import_module(mod)
  190. f = getattr(mod, f)
  191. res = f()
  192. results.append(PythonCode(func, None, None, res))
  193. # run shell or python callable object arguments
  194. elif isinstance(axn, OrderedDict):
  195. if 'shell' in axn:
  196. sh = Shell()
  197. cmd = axn['shell'].pop('cmd')
  198. assert isinstance(cmd, str)
  199. kwargs = axn['shell']
  200. sh.run(cmd, **kwargs)
  201. results.append(sh)
  202. if 'python' in axn:
  203. callables = axn['python']
  204. for func in callables:
  205. mod, f = func.split(':')
  206. mod = importlib.import_module(mod)
  207. try:
  208. f = getattr(mod, f)
  209. res = f()
  210. except AttributeError as E:
  211. res = E
  212. results.append(PythonCode(func, None, None, res))
  213. return results
  214. def clone(clone_url, branch, depth=1):
  215. cmd = ('git clone --depth={d} -b {branch} --single-branch '
  216. '{git_url} {dir}'.format(d=depth, branch=branch,
  217. git_url=clone_url, dir=branch))
  218. pull = sp.Popen(cmd, stderr=sp.STDOUT, shell=True)
  219. out, err = pull.communicate()
  220. return out, err
  221. @taskq.task()
  222. def count_beans():
  223. print "count_12"
  224. return "count_12"
  225. @app.route('/beans')
  226. def do_beans():
  227. a = count_beans()
  228. ans = a.get(blocking=True)
  229. return ans
  230. @app.route('/', methods=['POST'])
  231. def start():
  232. """
  233. main logic:
  234. 1. listen to post
  235. 2a if authenticated post do:
  236. 3. clone the latest commit (--depth 1)
  237. 4. parse yaml config
  238. 5. for each command in the config
  239. 7. run command
  240. 8. report success or failure
  241. """
  242. # This is authentication for github only
  243. # We could\should check for other hostings
  244. ans = hmac.new(app.config['POST_KEY'], request.data,
  245. hashlib.sha1).hexdigest()
  246. secret = request.headers['X-Hub-Signature'].split('=')[-1]
  247. if ans != secret:
  248. return abort(500)
  249. request_as_json = request.get_json()
  250. clone_dest = parse_branch_gh(request_as_json)
  251. repo_name = request_as_json["repository"]['name']
  252. try:
  253. o, e = clone(request_as_json['repository']['ssh_url'], clone_dest)
  254. except Exception as E:
  255. print E, E.message
  256. # parse yaml is still very crude ...
  257. # it could yield selfect with a run method
  258. # thus:
  259. # for action in parse_yaml(clone_dest):
  260. # action.run()
  261. #
  262. # this should also handle dependencies,
  263. # the following implementation is very crud
  264. failed = None
  265. for action in parse_yaml(clone_dest):
  266. # if config says we use huey, we should modiy run_action
  267. results = run_action(action)
  268. if any([result.code for result in results]):
  269. report_failure(results)
  270. else:
  271. report_success(results)
  272. @click.group()
  273. def cli():
  274. pass
  275. @cli.command('web', short_help='start the web service')
  276. @click.option('--port','-p', default=8080)
  277. @click.option('--debug', default=False, is_flag=True)
  278. def web(port, debug):
  279. click.echo("DEBUG: %s" % debug)
  280. app.run(host='0.0.0.0',port=port, debug=debug)
  281. @cli.command(context_settings=dict(
  282. ignore_unknown_options=True,
  283. allow_extra_args=True))
  284. @click.argument('worker_args', nargs=-1, type=click.UNPROCESSED)
  285. def worker(worker_args, short_help='start the consumer of tasks'):
  286. from huey.bin.huey_consumer import (get_option_parser, Consumer,
  287. setup_logger, RotatingFileHandler)
  288. from conf import taskq
  289. parser = get_option_parser()
  290. opts, args = parser.parse_args(list(worker_args))
  291. setup_logger(logging.INFO, opts.logfile)
  292. consumer = Consumer(taskq, 2, opts.periodic, opts.initial_delay,
  293. opts.backoff, opts.max_delay, opts.utc,
  294. opts.scheduler_interval, opts.periodic_task_interval)
  295. consumer.run()