frank.py 7.5 KB

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