frank.py 8.1 KB

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