brew.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import json
  2. import logging
  3. import os
  4. import random
  5. import re
  6. from shutil import rmtree
  7. import string
  8. import docker
  9. import git
  10. DEFAULT_REPOSITORY = 'git://github.com/dotcloud/stackbrew'
  11. DEFAULT_BRANCH = 'master'
  12. logger = logging.getLogger(__name__)
  13. logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
  14. level='INFO')
  15. def set_loglevel(level):
  16. logger.setLevel(level)
  17. git.logger.setLevel(level)
  18. class StackbrewError(Exception):
  19. def __init__(self, message, cause=None):
  20. super(StackbrewError, self).__init__(message)
  21. self.cause = cause
  22. def log(self, logger):
  23. logger.exception(self)
  24. if self.cause:
  25. logger.error('The cause of this error is the following:')
  26. logger.exception(self.cause)
  27. class StackbrewLibrary(object):
  28. def __init__(self, repository, branch=None):
  29. self.logger = logging.getLogger(__name__)
  30. logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s',
  31. level='INFO')
  32. self.branch = branch or DEFAULT_BRANCH
  33. self.repository = repository
  34. self.library = None
  35. if not self.repository.startswith(('https://', 'git://')):
  36. self.logger.info('Repository provided assumed to be a local path')
  37. self.library = self.repository
  38. def clone_library(self):
  39. if self.library:
  40. return self.library
  41. try:
  42. rep, library = git.clone_branch(self.repository, self.branch)
  43. self.library = library
  44. except git.GitException as e:
  45. raise StackbrewError(
  46. 'Source repository could not be fetched. Ensure '
  47. 'the address is correct and the branch exists.',
  48. e
  49. )
  50. def list_repositories(self):
  51. if not self.library:
  52. self.clone_library()
  53. try:
  54. return [e for e in os.listdir(
  55. os.path.join(self.library, 'library')) if e != 'MAINTAINERS']
  56. except OSError as e:
  57. raise StackbrewError(
  58. 'The path provided ({0}) could not be found or '
  59. 'didn\'t contain a library/ folder'.format(self.library),
  60. e
  61. )
  62. class StackbrewRepo(object):
  63. def __init__(self, name, definition_file):
  64. self.buildlist = {}
  65. self.buildorder = []
  66. self.git_folders = {}
  67. self.name = name
  68. for line in definition_file:
  69. if not line or line.strip() == '':
  70. continue
  71. elif line.lstrip().startswith('#'): # # It's a comment!
  72. continue
  73. logger.debug(line)
  74. tag, url, ref, dfile = self._parse_line(line)
  75. repo = (url, ref, dfile)
  76. if repo in self.buildlist:
  77. self.buildlist[repo].append(tag)
  78. else:
  79. self.buildlist[repo] = [tag]
  80. self.buildorder.append(repo)
  81. def _parse_line(self, line):
  82. df_folder = '.'
  83. args = line.split(':', 1)
  84. if len(args) != 2:
  85. logger.debug("Invalid line: {0}".format(line))
  86. raise StackbrewError(
  87. 'Incorrect line format, please refer to the docs'
  88. )
  89. try:
  90. repo = args[1].strip().split()
  91. if len(repo) == 2:
  92. df_folder = repo[1].strip()
  93. url, ref = repo[0].strip().rsplit('@', 1)
  94. return (args[0].strip(), url, ref, df_folder)
  95. except ValueError:
  96. logger.debug("Invalid line: {0}".format(line))
  97. raise StackbrewError(
  98. 'Incorrect line format, please refer to the docs'
  99. )
  100. def list_versions(self):
  101. return self.buildorder
  102. def get_associated_tags(self, repo):
  103. return self.buildlist.get(repo, None)
  104. def add_git_repo(self, url, repo):
  105. self.git_folders[url] = repo
  106. def get_git_repo(self, url):
  107. return self.git_folders.get(url, (None, None))
  108. class StackbrewBuilder(object):
  109. def __init__(self, library, namespaces=None, targetlist=None,
  110. repo_cache=None):
  111. self.lib = library
  112. if not hasattr(self.lib, 'list_repositories'):
  113. raise StackbrewError('Invalid library passed to StackbrewBuilder')
  114. self.namespaces = namespaces or ['stackbrew']
  115. self.targetlist = targetlist
  116. self.repo_cache = repo_cache
  117. self.history = {}
  118. def build_repo_list(self):
  119. self.repos = []
  120. for repo in self.lib.list_repositories():
  121. if self.targetlist and repo not in self.targetlist:
  122. continue
  123. try:
  124. with open(os.path.join(self.lib.library, 'library', repo)) as f:
  125. self.repos.append(StackbrewRepo(repo, f))
  126. except IOError as e:
  127. raise StackbrewError(
  128. 'Failed to read definition file for {0}'.format(repo),
  129. e
  130. )
  131. for repo in self.repos:
  132. for version in repo.list_versions():
  133. logger.debug('{0}: {1}'.format(
  134. repo.name,
  135. ','.join(repo.get_associated_tags(version))
  136. ))
  137. return self.repos
  138. def build_all(self, continue_on_error=True, callback=None):
  139. self.pushlist = []
  140. for repo in self.repos:
  141. self.build_repo(repo, continue_on_error, callback)
  142. for namespace in self.namespaces:
  143. if namespace != '':
  144. self.pushlist.append('/'.join([namespace, repo.name]))
  145. def build_repo(self, repo, continue_on_error=True, callback=None):
  146. for version in repo.list_versions():
  147. try:
  148. self.build_version(repo, version, callback)
  149. except StackbrewError as e:
  150. if not continue_on_error:
  151. raise e
  152. e.log(logger)
  153. def build_version(self, repo, version, callback=None):
  154. if version in self.history:
  155. return self.history[version], None
  156. url, ref, dfile = version
  157. try:
  158. rep, dst_folder = self.clone_version(repo, version)
  159. except StackbrewError as exc:
  160. if callback:
  161. callback(exc, repo, version, None, None)
  162. raise exc
  163. dockerfile_location = os.path.join(dst_folder, dfile)
  164. if not 'Dockerfile' in os.listdir(dockerfile_location):
  165. exc = StackbrewError('Dockerfile not found in cloned repository')
  166. if callback:
  167. callback(exc, repo, version, None, None)
  168. raise exc
  169. img_id, build_result = self.do_build(
  170. repo, version, dockerfile_location, callback
  171. )
  172. self.history[version] = img_id
  173. return img_id, build_result
  174. def do_build(self, repo, version, dockerfile_location, callback=None):
  175. raise NotImplementedError
  176. def _clone_or_checkout(self, url, ref, dst_folder, rep):
  177. if rep:
  178. try:
  179. # The ref already exists, we just need to checkout
  180. dst_folder = git.checkout(rep, ref)
  181. except git.GitException:
  182. # ref is not present, try pulling it from the remote origin
  183. rep, dst_folder = git.pull(url, rep, ref)
  184. return rep, dst_folder
  185. if dst_folder:
  186. rmtree(dst_folder)
  187. return git.clone(url, ref, dst_folder)
  188. def clone_version(self, repo, version):
  189. url, ref, dfile = version
  190. rep, dst_folder = repo.get_git_repo(url)
  191. if not dst_folder and self.repo_cache:
  192. dst_folder = os.path.join(
  193. self.repo_cache, repo.name + _random_suffix()
  194. )
  195. os.mkdir(dst_folder)
  196. try:
  197. rep, dst_folder = self._clone_or_checkout(
  198. url, ref, dst_folder, rep
  199. )
  200. except Exception as e:
  201. raise StackbrewError(
  202. 'Failed to clone repository {0}@{1}'.format(url, ref),
  203. e
  204. )
  205. repo.add_git_repo(url, (rep, dst_folder))
  206. return rep, dst_folder
  207. def get_pushlist(self):
  208. return self.pushlist
  209. def push_all(self, continue_on_error=True, callback=None):
  210. for repo in self.pushlist:
  211. try:
  212. self.do_push(repo, callback)
  213. except StackbrewError as e:
  214. if continue_on_error:
  215. e.log(logger)
  216. else:
  217. raise e
  218. def do_push(self, repo_name, callback=None):
  219. raise NotImplementedError
  220. def _random_suffix():
  221. return ''.join([
  222. random.choice(string.ascii_letters + string.digits) for i in xrange(6)
  223. ])
  224. class LocalBuilder(StackbrewBuilder):
  225. def __init__(self, library, namespaces=None, targetlist=None,
  226. repo_cache=None):
  227. super(LocalBuilder, self).__init__(
  228. library, namespaces, targetlist, repo_cache
  229. )
  230. self.client = docker.Client(version='1.9', timeout=10000,
  231. base_url=os.getenv('DOCKER_HOST'))
  232. self.build_success_re = r'^Successfully built ([a-f0-9]+)\n$'
  233. def do_build(self, repo, version, dockerfile_location, callback=None):
  234. logger.info(
  235. 'Build start: {0} {1}'.format(repo.name, version)
  236. )
  237. build_result = self.client.build(path=dockerfile_location, rm=True,
  238. stream=True, quiet=True)
  239. img_id, logs = self._parse_result(build_result)
  240. if not img_id:
  241. exc = StackbrewError(
  242. 'Build failed for {0} ({1})'.format(repo.name, version)
  243. )
  244. if callback:
  245. callback(exc, repo, version, None, logs)
  246. raise exc
  247. for tag in repo.get_associated_tags(version):
  248. logger.info(
  249. 'Build success: {0} ({1}:{2})'.format(img_id, repo.name, tag)
  250. )
  251. for namespace in self.namespaces:
  252. if namespace != '':
  253. name = '/'.join([namespace, repo.name])
  254. else:
  255. name = repo.name
  256. self.client.tag(img_id, name, tag)
  257. if callback:
  258. callback(None, repo, version, img_id, logs)
  259. return img_id, build_result
  260. def _parse_result(self, build_result):
  261. if isinstance(build_result, tuple):
  262. img_id, logs = build_result
  263. return img_id, logs
  264. else:
  265. lines = [line for line in build_result]
  266. try:
  267. parsed_lines = [json.loads(e).get('stream', '') for e in lines]
  268. except ValueError:
  269. # sometimes all the data is sent on a single line ????
  270. #
  271. # ValueError: Extra data: line 1 column 87 - line 1 column
  272. # 33268 (char 86 - 33267)
  273. line = lines[0]
  274. # This ONLY works because every line is formatted as
  275. # {"stream": STRING}
  276. parsed_lines = [
  277. json.loads(obj).get('stream', '') for obj in
  278. re.findall('{\s*"stream"\s*:\s*"[^"]*"\s*}', line)
  279. ]
  280. for line in parsed_lines:
  281. match = re.match(self.build_success_re, line)
  282. if match:
  283. return match.group(1), parsed_lines
  284. return None, parsed_lines
  285. def do_push(self, repo_name, callback=None):
  286. exc = None
  287. for i in xrange(4):
  288. try:
  289. pushlog = self.client.push(repo_name)
  290. if '"error":"' in pushlog:
  291. raise RuntimeError(
  292. 'Error while pushing: {0}'.format(pushlog)
  293. )
  294. logger.info('Succesfully pushed {0}'.format(repo_name))
  295. except Exception as e:
  296. exc = e
  297. continue
  298. if callback:
  299. callback(None, repo_name, pushlog)
  300. return
  301. if not callback:
  302. raise StackbrewError(
  303. 'Error while pushing {0}'.format(repo_name),
  304. exc
  305. )
  306. else:
  307. return callback(exc, repo_name, None)