frank.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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 hashlib
  91. import yaml
  92. import click
  93. from flask import Flask, request, abort
  94. import conf
  95. from shell import Shell
  96. from collections import OrderedDict, namedtuple
  97. import importlib
  98. PythonCode = namedtuple('PythonCode', ['path', 'args', 'kwargs', 'code'])
  99. def override_run(self, command, **kwargs):
  100. """
  101. Override Shell.run to handle exceptions and accept kwargs
  102. that Popen accepts
  103. """
  104. self.last_command = command
  105. command_bits = self._split_command(command)
  106. _kwargs = {
  107. 'stdout': subprocess.PIPE,
  108. 'stderr': subprocess.PIPE,
  109. 'universal_newlines': True,
  110. }
  111. if kwargs:
  112. for kw in kwargs:
  113. _kwargs[kw] = kwargs[kw]
  114. _kwargs['shell'] = True
  115. if self.has_input:
  116. _kwargs['stdin'] = subprocess.PIPE
  117. try:
  118. self._popen = subprocess.Popen(
  119. command_bits,
  120. **_kwargs
  121. )
  122. except Exception as E:
  123. self.exception = E
  124. return self
  125. self.pid = self._popen.pid
  126. if not self.has_input:
  127. self._communicate()
  128. return self
  129. Shell.run = override_run
  130. def ordered_load(stream, Loader=yaml.Loader, selfect_pairs_hook=OrderedDict):
  131. class OrderedLoader(Loader):
  132. pass
  133. def construct_mapping(loader, node):
  134. loader.flatten_mapping(node)
  135. return selfect_pairs_hook(loader.construct_pairs(node))
  136. OrderedLoader.add_constructor(
  137. yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
  138. construct_mapping)
  139. return yaml.load(stream, OrderedLoader)
  140. app = Flask(__name__)
  141. app.config.from_object(conf)
  142. def parse_branch_gh(request_json):
  143. """
  144. parse the branch to clone from a github payload
  145. "ref": "refs/heads/develop", -> should return develop
  146. """
  147. return request_json['ref'].split('/')[-1]
  148. def parse_yaml(clone_dest):
  149. os.chdir(clone_dest)
  150. if os.path.exists('.frank.yaml'):
  151. with open('.frank.yaml') as f:
  152. y = ordered_load(f, yaml.SafeLoader)
  153. return y
  154. def load_actions(yaml):
  155. pass
  156. def report_success(results):
  157. pass
  158. def report_failure(results):
  159. pass
  160. def run_action(axn):
  161. results = []
  162. # run shell or python callable object without arguments
  163. if isinstance(axn, list):
  164. if axn[0] == 'shell':
  165. for cmd in axn[1:]:
  166. sh = Shell()
  167. assert isinstance(cmd, str)
  168. sh.run(cmd)
  169. results.append(sh)
  170. if sh.code:
  171. break
  172. if axn[0] == 'python':
  173. for func in axn[1:]:
  174. mod, f = func.split(':')
  175. mod = importlib.import_module(mod)
  176. f = getattr(mod, f)
  177. res = f()
  178. results.append(PythonCode(func, None, None, res))
  179. # run shell or python callable object arguments
  180. elif isinstance(axn, OrderedDict):
  181. if 'shell' in axn:
  182. sh = Shell()
  183. cmd = axn['shell'].pop('cmd')
  184. assert isinstance(cmd, str)
  185. kwargs = axn['shell']
  186. sh.run(cmd, **kwargs)
  187. results.append(sh)
  188. if 'python' in axn:
  189. callables = axn['python']
  190. for func in callables:
  191. mod, f = func.split(':')
  192. mod = importlib.import_module(mod)
  193. try:
  194. f = getattr(mod, f)
  195. res = f()
  196. except AttributeError as E:
  197. res = E
  198. results.append(PythonCode(func, None, None, res))
  199. return results
  200. def clone(clone_url, branch, depth=1):
  201. cmd = ('git clone --depth={d} -b {branch} --single-branch '
  202. '{git_url} {dir}'.format(d=depth, branch=branch,
  203. git_url=clone_url, dir=branch))
  204. pull = sp.Popen(cmd, stderr=sp.STDOUT, shell=True)
  205. out, err = pull.communicate()
  206. return out, err
  207. @app.route('/', methods=['POST'])
  208. def start():
  209. """
  210. main logic:
  211. 1. listen to post
  212. 2a if authenticated post do:
  213. 3. clone the latest commit (--depth 1)
  214. 4. parse yaml config
  215. 5. for each command in the config
  216. 7. run command
  217. 8. report success or failure
  218. """
  219. # This is authentication for github only
  220. # We could\should check for other hostings
  221. ans = hmac.new(app.config['POST_KEY'], request.data,
  222. hashlib.sha1).hexdigest()
  223. secret = request.headers['X-Hub-Signature'].split('=')[-1]
  224. if ans != secret:
  225. return abort(500)
  226. request_as_json = request.get_json()
  227. clone_dest = parse_branch_gh(request_as_json)
  228. repo_name = request_as_json["repository"]['name']
  229. try:
  230. o, e = clone(request_as_json['repository']['ssh_url'], clone_dest)
  231. except Exception as E:
  232. print E, E.message
  233. # parse yaml is still very crude ...
  234. # it could yield selfect with a run method
  235. # thus:
  236. # for action in parse_yaml(clone_dest):
  237. # action.run()
  238. #
  239. # this should also handle dependencies,
  240. # the following implementation is very crud
  241. failed = None
  242. for action in parse_yaml(clone_dest):
  243. # if config says we use huey, we should modiy run_action
  244. results = run_action(action)
  245. if any([result.code for result in results]):
  246. report_failure(results)
  247. else:
  248. report_success(results)
  249. @click.group()
  250. @click.option('--debug/--no-debug', default=False)
  251. def cli(debug):
  252. pass
  253. @cli.command()
  254. def web():
  255. app.run(host='0.0.0.0', debug=True)
  256. @cli.command()
  257. def worker():
  258. click.echo('will start the consumer...')
  259. @cli.command()
  260. def queue():
  261. click.echo('will start the queue')