localhostbecontroller.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. #
  2. # ex:ts=4:sw=4:sts=4:et
  3. # -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
  4. #
  5. # BitBake Toaster Implementation
  6. #
  7. # Copyright (C) 2014 Intel Corporation
  8. #
  9. # SPDX-License-Identifier: GPL-2.0-only
  10. #
  11. import os
  12. import sys
  13. import re
  14. import shutil
  15. import time
  16. from django.db import transaction
  17. from django.db.models import Q
  18. from bldcontrol.models import BuildEnvironment, BuildRequest, BRLayer, BRVariable, BRTarget, BRBitbake, Build
  19. from orm.models import CustomImageRecipe, Layer, Layer_Version, Project, ProjectLayer, ToasterSetting
  20. from orm.models import signal_runbuilds
  21. import subprocess
  22. from toastermain import settings
  23. from bldcontrol.bbcontroller import BuildEnvironmentController, ShellCmdException, BuildSetupException, BitbakeController
  24. import logging
  25. logger = logging.getLogger("toaster")
  26. install_dir = os.environ.get('TOASTER_DIR')
  27. from pprint import pprint, pformat
  28. class LocalhostBEController(BuildEnvironmentController):
  29. """ Implementation of the BuildEnvironmentController for the localhost;
  30. this controller manages the default build directory,
  31. the server setup and system start and stop for the localhost-type build environment
  32. """
  33. def __init__(self, be):
  34. super(LocalhostBEController, self).__init__(be)
  35. self.pokydirname = None
  36. self.islayerset = False
  37. def _shellcmd(self, command, cwd=None, nowait=False,env=None):
  38. if cwd is None:
  39. cwd = self.be.sourcedir
  40. if env is None:
  41. env=os.environ.copy()
  42. logger.debug("lbc_shellcmd: (%s) %s" % (cwd, command))
  43. p = subprocess.Popen(command, cwd = cwd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
  44. if nowait:
  45. return
  46. (out,err) = p.communicate()
  47. p.wait()
  48. if p.returncode:
  49. if len(err) == 0:
  50. err = "command: %s \n%s" % (command, out)
  51. else:
  52. err = "command: %s \n%s" % (command, err)
  53. logger.warning("localhostbecontroller: shellcmd error %s" % err)
  54. raise ShellCmdException(err)
  55. else:
  56. logger.debug("localhostbecontroller: shellcmd success")
  57. return out.decode('utf-8')
  58. def getGitCloneDirectory(self, url, branch):
  59. """Construct unique clone directory name out of url and branch."""
  60. if branch != "HEAD":
  61. return "_toaster_clones/_%s_%s" % (re.sub('[:/@+%]', '_', url), branch)
  62. # word of attention; this is a localhost-specific issue; only on the localhost we expect to have "HEAD" releases
  63. # which _ALWAYS_ means the current poky checkout
  64. from os.path import dirname as DN
  65. local_checkout_path = DN(DN(DN(DN(DN(os.path.abspath(__file__))))))
  66. #logger.debug("localhostbecontroller: using HEAD checkout in %s" % local_checkout_path)
  67. return local_checkout_path
  68. def setCloneStatus(self,bitbake,status,total,current,repo_name):
  69. bitbake.req.build.repos_cloned=current
  70. bitbake.req.build.repos_to_clone=total
  71. bitbake.req.build.progress_item=repo_name
  72. bitbake.req.build.save()
  73. def setLayers(self, bitbake, layers, targets):
  74. """ a word of attention: by convention, the first layer for any build will be poky! """
  75. assert self.be.sourcedir is not None
  76. layerlist = []
  77. nongitlayerlist = []
  78. layer_index = 0
  79. git_env = os.environ.copy()
  80. # (note: add custom environment settings here)
  81. # set layers in the layersource
  82. # 1. get a list of repos with branches, and map dirpaths for each layer
  83. gitrepos = {}
  84. # if we're using a remotely fetched version of bitbake add its git
  85. # details to the list of repos to clone
  86. if bitbake.giturl and bitbake.commit:
  87. gitrepos[(bitbake.giturl, bitbake.commit)] = []
  88. gitrepos[(bitbake.giturl, bitbake.commit)].append(
  89. ("bitbake", bitbake.dirpath, 0))
  90. for layer in layers:
  91. # We don't need to git clone the layer for the CustomImageRecipe
  92. # as it's generated by us layer on if needed
  93. if CustomImageRecipe.LAYER_NAME in layer.name:
  94. continue
  95. # If we have local layers then we don't need clone them
  96. # For local layers giturl will be empty
  97. if not layer.giturl:
  98. nongitlayerlist.append( "%03d:%s" % (layer_index,layer.local_source_dir) )
  99. continue
  100. if not (layer.giturl, layer.commit) in gitrepos:
  101. gitrepos[(layer.giturl, layer.commit)] = []
  102. gitrepos[(layer.giturl, layer.commit)].append( (layer.name,layer.dirpath,layer_index) )
  103. layer_index += 1
  104. logger.debug("localhostbecontroller, our git repos are %s" % pformat(gitrepos))
  105. # 2. Note for future use if the current source directory is a
  106. # checked-out git repos that could match a layer's vcs_url and therefore
  107. # be used to speed up cloning (rather than fetching it again).
  108. cached_layers = {}
  109. try:
  110. for remotes in self._shellcmd("git remote -v", self.be.sourcedir,env=git_env).split("\n"):
  111. try:
  112. remote = remotes.split("\t")[1].split(" ")[0]
  113. if remote not in cached_layers:
  114. cached_layers[remote] = self.be.sourcedir
  115. except IndexError:
  116. pass
  117. except ShellCmdException:
  118. # ignore any errors in collecting git remotes this is an optional
  119. # step
  120. pass
  121. logger.info("Using pre-checked out source for layer %s", cached_layers)
  122. # 3. checkout the repositories
  123. clone_count=0
  124. clone_total=len(gitrepos.keys())
  125. self.setCloneStatus(bitbake,'Started',clone_total,clone_count,'')
  126. for giturl, commit in gitrepos.keys():
  127. self.setCloneStatus(bitbake,'progress',clone_total,clone_count,gitrepos[(giturl, commit)][0][0])
  128. clone_count += 1
  129. localdirname = os.path.join(self.be.sourcedir, self.getGitCloneDirectory(giturl, commit))
  130. logger.debug("localhostbecontroller: giturl %s:%s checking out in current directory %s" % (giturl, commit, localdirname))
  131. # see if our directory is a git repository
  132. if os.path.exists(localdirname):
  133. try:
  134. localremotes = self._shellcmd("git remote -v",
  135. localdirname,env=git_env)
  136. # NOTE: this nice-to-have check breaks when using git remaping to get past firewall
  137. # Re-enable later with .gitconfig remapping checks
  138. #if not giturl in localremotes and commit != 'HEAD':
  139. # raise BuildSetupException("Existing git repository at %s, but with different remotes ('%s', expected '%s'). Toaster will not continue out of fear of damaging something." % (localdirname, ", ".join(localremotes.split("\n")), giturl))
  140. pass
  141. except ShellCmdException:
  142. # our localdirname might not be a git repository
  143. #- that's fine
  144. pass
  145. else:
  146. if giturl in cached_layers:
  147. logger.debug("localhostbecontroller git-copying %s to %s" % (cached_layers[giturl], localdirname))
  148. self._shellcmd("git clone \"%s\" \"%s\"" % (cached_layers[giturl], localdirname),env=git_env)
  149. self._shellcmd("git remote remove origin", localdirname,env=git_env)
  150. self._shellcmd("git remote add origin \"%s\"" % giturl, localdirname,env=git_env)
  151. else:
  152. logger.debug("localhostbecontroller: cloning %s in %s" % (giturl, localdirname))
  153. self._shellcmd('git clone "%s" "%s"' % (giturl, localdirname),env=git_env)
  154. # branch magic name "HEAD" will inhibit checkout
  155. if commit != "HEAD":
  156. logger.debug("localhostbecontroller: checking out commit %s to %s " % (commit, localdirname))
  157. ref = commit if re.match('^[a-fA-F0-9]+$', commit) else 'origin/%s' % commit
  158. self._shellcmd('git fetch && git reset --hard "%s"' % ref, localdirname,env=git_env)
  159. # take the localdirname as poky dir if we can find the oe-init-build-env
  160. if self.pokydirname is None and os.path.exists(os.path.join(localdirname, "oe-init-build-env")):
  161. logger.debug("localhostbecontroller: selected poky dir name %s" % localdirname)
  162. self.pokydirname = localdirname
  163. # make sure we have a working bitbake
  164. if not os.path.exists(os.path.join(self.pokydirname, 'bitbake')):
  165. logger.debug("localhostbecontroller: checking bitbake into the poky dirname %s " % self.pokydirname)
  166. self._shellcmd("git clone -b \"%s\" \"%s\" \"%s\" " % (bitbake.commit, bitbake.giturl, os.path.join(self.pokydirname, 'bitbake')),env=git_env)
  167. # verify our repositories
  168. for name, dirpath, index in gitrepos[(giturl, commit)]:
  169. localdirpath = os.path.join(localdirname, dirpath)
  170. logger.debug("localhostbecontroller: localdirpath expects '%s'" % localdirpath)
  171. if not os.path.exists(localdirpath):
  172. raise BuildSetupException("Cannot find layer git path '%s' in checked out repository '%s:%s'. Aborting." % (localdirpath, giturl, commit))
  173. if name != "bitbake":
  174. layerlist.append("%03d:%s" % (index,localdirpath.rstrip("/")))
  175. self.setCloneStatus(bitbake,'complete',clone_total,clone_count,'')
  176. logger.debug("localhostbecontroller: current layer list %s " % pformat(layerlist))
  177. # Resolve self.pokydirname if not resolved yet, consider the scenario
  178. # where all layers are local, that's the else clause
  179. if self.pokydirname is None:
  180. if os.path.exists(os.path.join(self.be.sourcedir, "oe-init-build-env")):
  181. logger.debug("localhostbecontroller: selected poky dir name %s" % self.be.sourcedir)
  182. self.pokydirname = self.be.sourcedir
  183. else:
  184. # Alternatively, scan local layers for relative "oe-init-build-env" location
  185. for layer in layers:
  186. if os.path.exists(os.path.join(layer.layer_version.layer.local_source_dir,"..","oe-init-build-env")):
  187. logger.debug("localhostbecontroller, setting pokydirname to %s" % (layer.layer_version.layer.local_source_dir))
  188. self.pokydirname = os.path.join(layer.layer_version.layer.local_source_dir,"..")
  189. break
  190. else:
  191. logger.error("pokydirname is not set, you will run into trouble!")
  192. # 5. create custom layer and add custom recipes to it
  193. for target in targets:
  194. try:
  195. customrecipe = CustomImageRecipe.objects.get(
  196. name=target.target,
  197. project=bitbake.req.project)
  198. custom_layer_path = self.setup_custom_image_recipe(
  199. customrecipe, layers)
  200. if os.path.isdir(custom_layer_path):
  201. layerlist.append("%03d:%s" % (layer_index,custom_layer_path))
  202. except CustomImageRecipe.DoesNotExist:
  203. continue # not a custom recipe, skip
  204. layerlist.extend(nongitlayerlist)
  205. logger.debug("\n\nset layers gives this list %s" % pformat(layerlist))
  206. self.islayerset = True
  207. # restore the order of layer list for bblayers.conf
  208. layerlist.sort()
  209. sorted_layerlist = [l[4:] for l in layerlist]
  210. return sorted_layerlist
  211. def setup_custom_image_recipe(self, customrecipe, layers):
  212. """ Set up toaster-custom-images layer and recipe files """
  213. layerpath = os.path.join(self.be.builddir,
  214. CustomImageRecipe.LAYER_NAME)
  215. # create directory structure
  216. for name in ("conf", "recipes"):
  217. path = os.path.join(layerpath, name)
  218. if not os.path.isdir(path):
  219. os.makedirs(path)
  220. # create layer.conf
  221. config = os.path.join(layerpath, "conf", "layer.conf")
  222. if not os.path.isfile(config):
  223. with open(config, "w") as conf:
  224. conf.write('BBPATH .= ":${LAYERDIR}"\nBBFILES += "${LAYERDIR}/recipes/*.bb"\n')
  225. # Update the Layer_Version dirpath that has our base_recipe in
  226. # to be able to read the base recipe to then generate the
  227. # custom recipe.
  228. br_layer_base_recipe = layers.get(
  229. layer_version=customrecipe.base_recipe.layer_version)
  230. # If the layer is one that we've cloned we know where it lives
  231. if br_layer_base_recipe.giturl and br_layer_base_recipe.commit:
  232. layer_path = self.getGitCloneDirectory(
  233. br_layer_base_recipe.giturl,
  234. br_layer_base_recipe.commit)
  235. # Otherwise it's a local layer
  236. elif br_layer_base_recipe.local_source_dir:
  237. layer_path = br_layer_base_recipe.local_source_dir
  238. else:
  239. logger.error("Unable to workout the dir path for the custom"
  240. " image recipe")
  241. br_layer_base_dirpath = os.path.join(
  242. self.be.sourcedir,
  243. layer_path,
  244. customrecipe.base_recipe.layer_version.dirpath)
  245. customrecipe.base_recipe.layer_version.dirpath = br_layer_base_dirpath
  246. customrecipe.base_recipe.layer_version.save()
  247. # create recipe
  248. recipe_path = os.path.join(layerpath, "recipes", "%s.bb" %
  249. customrecipe.name)
  250. with open(recipe_path, "w") as recipef:
  251. recipef.write(customrecipe.generate_recipe_file_contents())
  252. # Update the layer and recipe objects
  253. customrecipe.layer_version.dirpath = layerpath
  254. customrecipe.layer_version.layer.local_source_dir = layerpath
  255. customrecipe.layer_version.layer.save()
  256. customrecipe.layer_version.save()
  257. customrecipe.file_path = recipe_path
  258. customrecipe.save()
  259. return layerpath
  260. def readServerLogFile(self):
  261. return open(os.path.join(self.be.builddir, "toaster_server.log"), "r").read()
  262. def triggerBuild(self, bitbake, layers, variables, targets, brbe):
  263. layers = self.setLayers(bitbake, layers, targets)
  264. is_merged_attr = bitbake.req.project.merged_attr
  265. git_env = os.environ.copy()
  266. # (note: add custom environment settings here)
  267. try:
  268. # insure that the project init/build uses the selected bitbake, and not Toaster's
  269. del git_env['TEMPLATECONF']
  270. del git_env['BBBASEDIR']
  271. del git_env['BUILDDIR']
  272. except KeyError:
  273. pass
  274. # init build environment from the clone
  275. if bitbake.req.project.builddir:
  276. builddir = bitbake.req.project.builddir
  277. else:
  278. builddir = '%s-toaster-%d' % (self.be.builddir, bitbake.req.project.id)
  279. oe_init = os.path.join(self.pokydirname, 'oe-init-build-env')
  280. # init build environment
  281. try:
  282. custom_script = ToasterSetting.objects.get(name="CUSTOM_BUILD_INIT_SCRIPT").value
  283. custom_script = custom_script.replace("%BUILDDIR%" ,builddir)
  284. self._shellcmd("bash -c 'source %s'" % (custom_script),env=git_env)
  285. except ToasterSetting.DoesNotExist:
  286. self._shellcmd("bash -c 'source %s %s'" % (oe_init, builddir),
  287. self.be.sourcedir,env=git_env)
  288. # update bblayers.conf
  289. if not is_merged_attr:
  290. bblconfpath = os.path.join(builddir, "conf/toaster-bblayers.conf")
  291. with open(bblconfpath, 'w') as bblayers:
  292. bblayers.write('# line added by toaster build control\n'
  293. 'BBLAYERS = "%s"' % ' '.join(layers))
  294. # write configuration file
  295. confpath = os.path.join(builddir, 'conf/toaster.conf')
  296. with open(confpath, 'w') as conf:
  297. for var in variables:
  298. conf.write('%s="%s"\n' % (var.name, var.value))
  299. conf.write('INHERIT+="toaster buildhistory"')
  300. else:
  301. # Append the Toaster-specific values directly to the bblayers.conf
  302. bblconfpath = os.path.join(builddir, "conf/bblayers.conf")
  303. bblconfpath_save = os.path.join(builddir, "conf/bblayers.conf.save")
  304. shutil.copyfile(bblconfpath, bblconfpath_save)
  305. with open(bblconfpath) as bblayers:
  306. content = bblayers.readlines()
  307. do_write = True
  308. was_toaster = False
  309. with open(bblconfpath,'w') as bblayers:
  310. for line in content:
  311. #line = line.strip('\n')
  312. if 'TOASTER_CONFIG_PROLOG' in line:
  313. do_write = False
  314. was_toaster = True
  315. elif 'TOASTER_CONFIG_EPILOG' in line:
  316. do_write = True
  317. elif do_write:
  318. bblayers.write(line)
  319. if not was_toaster:
  320. bblayers.write('\n')
  321. bblayers.write('#=== TOASTER_CONFIG_PROLOG ===\n')
  322. bblayers.write('BBLAYERS = "\\\n')
  323. for layer in layers:
  324. bblayers.write(' %s \\\n' % layer)
  325. bblayers.write(' "\n')
  326. bblayers.write('#=== TOASTER_CONFIG_EPILOG ===\n')
  327. # Append the Toaster-specific values directly to the local.conf
  328. bbconfpath = os.path.join(builddir, "conf/local.conf")
  329. bbconfpath_save = os.path.join(builddir, "conf/local.conf.save")
  330. shutil.copyfile(bbconfpath, bbconfpath_save)
  331. with open(bbconfpath) as f:
  332. content = f.readlines()
  333. do_write = True
  334. was_toaster = False
  335. with open(bbconfpath,'w') as conf:
  336. for line in content:
  337. #line = line.strip('\n')
  338. if 'TOASTER_CONFIG_PROLOG' in line:
  339. do_write = False
  340. was_toaster = True
  341. elif 'TOASTER_CONFIG_EPILOG' in line:
  342. do_write = True
  343. elif do_write:
  344. conf.write(line)
  345. if not was_toaster:
  346. conf.write('\n')
  347. conf.write('#=== TOASTER_CONFIG_PROLOG ===\n')
  348. for var in variables:
  349. if (not var.name.startswith("INTERNAL_")) and (not var.name == "BBLAYERS"):
  350. conf.write('%s="%s"\n' % (var.name, var.value))
  351. conf.write('#=== TOASTER_CONFIG_EPILOG ===\n')
  352. # If 'target' is just the project preparation target, then we are done
  353. for target in targets:
  354. if "_PROJECT_PREPARE_" == target.target:
  355. logger.debug('localhostbecontroller: Project has been prepared. Done.')
  356. # Update the Build Request and release the build environment
  357. bitbake.req.state = BuildRequest.REQ_COMPLETED
  358. bitbake.req.save()
  359. self.be.lock = BuildEnvironment.LOCK_FREE
  360. self.be.save()
  361. # Close the project build and progress bar
  362. bitbake.req.build.outcome = Build.SUCCEEDED
  363. bitbake.req.build.save()
  364. # Update the project status
  365. bitbake.req.project.set_variable(Project.PROJECT_SPECIFIC_STATUS,Project.PROJECT_SPECIFIC_CLONING_SUCCESS)
  366. signal_runbuilds()
  367. return
  368. # clean the Toaster to build environment
  369. env_clean = 'unset BBPATH;' # clean BBPATH for <= YP-2.4.0
  370. # run bitbake server from the clone if available
  371. # otherwise pick it from the PATH
  372. bitbake = os.path.join(self.pokydirname, 'bitbake', 'bin', 'bitbake')
  373. if not os.path.exists(bitbake):
  374. logger.info("Bitbake not available under %s, will try to use it from PATH" %
  375. self.pokydirname)
  376. for path in os.environ["PATH"].split(os.pathsep):
  377. if os.path.exists(os.path.join(path, 'bitbake')):
  378. bitbake = os.path.join(path, 'bitbake')
  379. break
  380. else:
  381. logger.error("Looks like Bitbake is not available, please fix your environment")
  382. toasterlayers = os.path.join(builddir,"conf/toaster-bblayers.conf")
  383. if not is_merged_attr:
  384. self._shellcmd('%s bash -c \"source %s %s; BITBAKE_UI="knotty" %s --read %s --read %s '
  385. '--server-only -B 0.0.0.0:0\"' % (env_clean, oe_init,
  386. builddir, bitbake, confpath, toasterlayers), self.be.sourcedir)
  387. else:
  388. self._shellcmd('%s bash -c \"source %s %s; BITBAKE_UI="knotty" %s '
  389. '--server-only -B 0.0.0.0:0\"' % (env_clean, oe_init,
  390. builddir, bitbake), self.be.sourcedir)
  391. # read port number from bitbake.lock
  392. self.be.bbport = -1
  393. bblock = os.path.join(builddir, 'bitbake.lock')
  394. # allow 10 seconds for bb lock file to appear but also be populated
  395. for lock_check in range(10):
  396. if not os.path.exists(bblock):
  397. logger.debug("localhostbecontroller: waiting for bblock file to appear")
  398. time.sleep(1)
  399. continue
  400. if 10 < os.stat(bblock).st_size:
  401. break
  402. logger.debug("localhostbecontroller: waiting for bblock content to appear")
  403. time.sleep(1)
  404. else:
  405. raise BuildSetupException("Cannot find bitbake server lock file '%s'. Aborting." % bblock)
  406. with open(bblock) as fplock:
  407. for line in fplock:
  408. if ":" in line:
  409. self.be.bbport = line.split(":")[-1].strip()
  410. logger.debug("localhostbecontroller: bitbake port %s", self.be.bbport)
  411. break
  412. if -1 == self.be.bbport:
  413. raise BuildSetupException("localhostbecontroller: can't read bitbake port from %s" % bblock)
  414. self.be.bbaddress = "localhost"
  415. self.be.bbstate = BuildEnvironment.SERVER_STARTED
  416. self.be.lock = BuildEnvironment.LOCK_RUNNING
  417. self.be.save()
  418. bbtargets = ''
  419. for target in targets:
  420. task = target.task
  421. if task:
  422. if not task.startswith('do_'):
  423. task = 'do_' + task
  424. task = ':%s' % task
  425. bbtargets += '%s%s ' % (target.target, task)
  426. # run build with local bitbake. stop the server after the build.
  427. log = os.path.join(builddir, 'toaster_ui.log')
  428. local_bitbake = os.path.join(os.path.dirname(os.getenv('BBBASEDIR')),
  429. 'bitbake')
  430. if not is_merged_attr:
  431. self._shellcmd(['%s bash -c \"(TOASTER_BRBE="%s" BBSERVER="0.0.0.0:%s" '
  432. '%s %s -u toasterui --read %s --read %s --token="" >>%s 2>&1;'
  433. 'BITBAKE_UI="knotty" BBSERVER=0.0.0.0:%s %s -m)&\"' \
  434. % (env_clean, brbe, self.be.bbport, local_bitbake, bbtargets, confpath, toasterlayers, log,
  435. self.be.bbport, bitbake,)],
  436. builddir, nowait=True)
  437. else:
  438. self._shellcmd(['%s bash -c \"(TOASTER_BRBE="%s" BBSERVER="0.0.0.0:%s" '
  439. '%s %s -u toasterui --token="" >>%s 2>&1;'
  440. 'BITBAKE_UI="knotty" BBSERVER=0.0.0.0:%s %s -m)&\"' \
  441. % (env_clean, brbe, self.be.bbport, local_bitbake, bbtargets, log,
  442. self.be.bbport, bitbake,)],
  443. builddir, nowait=True)
  444. logger.debug('localhostbecontroller: Build launched, exiting. '
  445. 'Follow build logs at %s' % log)