frank.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  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. import pickle
  102. # monkey patch data store
  103. _list = "select * FROM {0}"
  104. def list_results(obj):
  105. with obj._db.get_connection() as conn:
  106. try:
  107. return list(conn.execute(obj._list.format(obj.name)))
  108. except:
  109. return None
  110. taskq.result_store._list = _list
  111. taskq.result_store.list_results = types.MethodType(list_results,
  112. taskq.result_store)
  113. PythonCode = namedtuple('PythonCode', ['path', 'args', 'kwargs', 'code'])
  114. def override_run(self, command, **kwargs):
  115. """
  116. Override Shell.run to handle exceptions and accept kwargs
  117. that Popen accepts
  118. """
  119. self.last_command = command
  120. command_bits = self._split_command(command)
  121. _kwargs = {
  122. 'stdout': subprocess.PIPE,
  123. 'stderr': subprocess.PIPE,
  124. 'universal_newlines': True,
  125. }
  126. if kwargs:
  127. for kw in kwargs:
  128. _kwargs[kw] = kwargs[kw]
  129. _kwargs['shell'] = True
  130. if self.has_input:
  131. _kwargs['stdin'] = subprocess.PIPE
  132. try:
  133. self._popen = subprocess.Popen(
  134. command_bits,
  135. **_kwargs
  136. )
  137. except Exception as E:
  138. self.exception = E
  139. return self
  140. self.pid = self._popen.pid
  141. if not self.has_input:
  142. self._communicate()
  143. return self
  144. Shell.run = override_run
  145. def ordered_load(stream, Loader=yaml.Loader, selfect_pairs_hook=OrderedDict):
  146. class OrderedLoader(Loader):
  147. pass
  148. def construct_mapping(loader, node):
  149. loader.flatten_mapping(node)
  150. return selfect_pairs_hook(loader.construct_pairs(node))
  151. OrderedLoader.add_constructor(
  152. yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
  153. construct_mapping)
  154. return yaml.load(stream, OrderedLoader)
  155. app = Flask(__name__)
  156. app.config.from_object(conf)
  157. def parse_branch_gh(request_json):
  158. """
  159. parse the branch to clone from a github payload
  160. "ref": "refs/heads/develop", -> should return develop
  161. """
  162. return request_json['ref'].split('/')[-1]
  163. def parse_yaml(clone_dest):
  164. os.chdir(clone_dest)
  165. if os.path.exists('.frank.yaml'):
  166. with open('.frank.yaml') as f:
  167. y = ordered_load(f, yaml.SafeLoader)
  168. return y
  169. def load_actions(yaml):
  170. pass
  171. def report_success(results):
  172. pass
  173. def report_failure(results):
  174. pass
  175. def run_action(axn):
  176. results = []
  177. # run shell or python callable object without arguments
  178. if isinstance(axn, list):
  179. if axn[0] == 'shell':
  180. for cmd in axn[1:]:
  181. sh = Shell()
  182. assert isinstance(cmd, str)
  183. sh.run(cmd)
  184. results.append(sh)
  185. if sh.code:
  186. break
  187. if axn[0] == 'python':
  188. for func in axn[1:]:
  189. mod, f = func.split(':')
  190. mod = importlib.import_module(mod)
  191. f = getattr(mod, f)
  192. res = f()
  193. results.append(PythonCode(func, None, None, res))
  194. # run shell or python callable object arguments
  195. elif isinstance(axn, OrderedDict):
  196. if 'shell' in axn:
  197. sh = Shell()
  198. cmd = axn['shell'].pop('cmd')
  199. assert isinstance(cmd, str)
  200. kwargs = axn['shell']
  201. sh.run(cmd, **kwargs)
  202. results.append(sh)
  203. if 'python' in axn:
  204. callables = axn['python']
  205. for func in callables:
  206. mod, f = func.split(':')
  207. mod = importlib.import_module(mod)
  208. try:
  209. f = getattr(mod, f)
  210. res = f()
  211. except AttributeError as E:
  212. res = E
  213. results.append(PythonCode(func, None, None, res))
  214. return results
  215. def clone(clone_url, branch, depth=1):
  216. cmd = ('git clone --depth={d} -b {branch} --single-branch '
  217. '{git_url} {dir}'.format(d=depth, branch=branch,
  218. git_url=clone_url, dir=branch))
  219. pull = sp.Popen(cmd, stderr=sp.STDOUT, shell=True)
  220. out, err = pull.communicate()
  221. return out, err
  222. @taskq.task()
  223. def count_beans():
  224. print "count_12"
  225. return "count_12"
  226. @app.route('/beans')
  227. def do_beans():
  228. a = count_beans()
  229. ans = a.get(blocking=True)
  230. return ans
  231. @app.route('/results')
  232. def show_resutls():
  233. res = {r[1]:r[2] for r in taskq.result_store.list_results()}
  234. return ''.join(['<p>'+str(k) + ': ' + str(pickle.loads(v)) + '</p>\n' for
  235. (k, v) in res.iteritems()])
  236. @app.route('/', methods=['POST'])
  237. def start():
  238. """
  239. main logic:
  240. 1. listen to post
  241. 2a if authenticated post do:
  242. 3. clone the latest commit (--depth 1)
  243. 4. parse yaml config
  244. 5. for each command in the config
  245. 7. run command
  246. 8. report success or failure
  247. """
  248. # This is authentication for github only
  249. # We could\should check for other hostings
  250. ans = hmac.new(app.config['POST_KEY'], request.data,
  251. hashlib.sha1).hexdigest()
  252. secret = request.headers['X-Hub-Signature'].split('=')[-1]
  253. if ans != secret:
  254. return abort(500)
  255. request_as_json = request.get_json()
  256. clone_dest = parse_branch_gh(request_as_json)
  257. repo_name = request_as_json["repository"]['name']
  258. try:
  259. o, e = clone(request_as_json['repository']['ssh_url'], clone_dest)
  260. except Exception as E:
  261. print E, E.message
  262. # parse yaml is still very crude ...
  263. # it could yield selfect with a run method
  264. # thus:
  265. # for action in parse_yaml(clone_dest):
  266. # action.run()
  267. #
  268. # this should also handle dependencies,
  269. # the following implementation is very crud
  270. failed = None
  271. for action in parse_yaml(clone_dest):
  272. # if config says we use huey, we should modiy run_action
  273. results = run_action(action)
  274. if any([result.code for result in results]):
  275. report_failure(results)
  276. else:
  277. report_success(results)
  278. @click.group()
  279. def cli():
  280. pass
  281. @cli.command('web', short_help='start the web service')
  282. @click.option('--port','-p', default=8080)
  283. @click.option('--debug', default=False, is_flag=True)
  284. def web(port, debug):
  285. click.echo("DEBUG: %s" % debug)
  286. app.run(host='0.0.0.0',port=port, debug=debug)
  287. @cli.command(context_settings=dict(
  288. ignore_unknown_options=True,
  289. allow_extra_args=True))
  290. @click.argument('worker_args', nargs=-1, type=click.UNPROCESSED)
  291. def worker(worker_args, short_help='start the consumer of tasks'):
  292. from huey.bin.huey_consumer import (get_option_parser, Consumer,
  293. setup_logger, RotatingFileHandler)
  294. from conf import taskq
  295. parser = get_option_parser()
  296. opts, args = parser.parse_args(list(worker_args))
  297. setup_logger(logging.INFO, opts.logfile)
  298. consumer = Consumer(taskq, 2, opts.periodic, opts.initial_delay,
  299. opts.backoff, opts.max_delay, opts.utc,
  300. opts.scheduler_interval, opts.periodic_task_interval)
  301. consumer.run()