app.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. #!/usr/bin/env python3
  2. import os
  3. import subprocess
  4. import json
  5. import pathlib
  6. import shutil
  7. import glob
  8. import time
  9. import fcntl
  10. import hashlib
  11. from distutils.dir_util import copy_tree
  12. from flask import Flask, render_template, request, send_from_directory, render_template_string
  13. from threading import Thread, Lock
  14. # run at lower priority
  15. os.nice(20)
  16. #BOARDS = [ 'BeastF7', 'BeastH7' ]
  17. appdir = os.path.dirname(__file__)
  18. VEHICLES = [ 'Copter', 'Plane', 'Rover', 'Sub' ]
  19. default_vehicle = 'Copter'
  20. def get_boards():
  21. '''return a list of boards to build'''
  22. import importlib.util
  23. spec = importlib.util.spec_from_file_location("build_binaries.py",
  24. os.path.join(sourcedir,
  25. 'Tools', 'scripts',
  26. 'board_list.py'))
  27. mod = importlib.util.module_from_spec(spec)
  28. spec.loader.exec_module(mod)
  29. default_board = mod.AUTOBUILD_BOARDS[11]
  30. return (mod.AUTOBUILD_BOARDS, default_board)
  31. # list of build options to offer
  32. # (label, define, text, default, category)
  33. BUILD_OPTIONS = [
  34. ('EKF2', 'HAL_NAVEKF2_AVAILABLE', 'Enable EKF2', '1', 'EKF'),
  35. ('EKF3', 'HAL_NAVEKF3_AVAILABLE', 'Enable EKF3', '0', 'EKF'),
  36. ('DSP', 'HAL_WITH_DSP', 'Enable DSP', '1', 'Other'),
  37. ('SPRAYER', 'HAL_SPRAYER_ENABLED', 'Enable Sprayer', '0', 'Other'),
  38. ('PARACHUTE', 'HAL_PARACHUTE_ENABLED', 'Enable Parachute', '0', 'Other'),
  39. ('MOUNT', 'HAL_MOUNT_ENABLED', 'Enable Mount', '0', 'Other'),
  40. ('HOTT_TELEM', 'HAL_HOTT_TELEM_ENABLED', 'Enable HoTT Telemetry', '0', 'Other'),
  41. ('BATTMON_FUEL', 'HAL_BATTMON_FUEL_ENABLE', 'Enable Fuel BatteryMonitor', '0', 'Other')
  42. ]
  43. def build_options_sort(e):
  44. return e[4]
  45. BUILD_OPTIONS.sort(key=build_options_sort)
  46. queue_lock = Lock()
  47. from logging.config import dictConfig
  48. dictConfig({
  49. 'version': 1,
  50. 'formatters': {'default': {
  51. 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
  52. }},
  53. 'handlers': {'wsgi': {
  54. 'class': 'logging.StreamHandler',
  55. 'stream': 'ext://flask.logging.wsgi_errors_stream',
  56. 'formatter': 'default'
  57. }},
  58. 'root': {
  59. 'level': 'INFO',
  60. 'handlers': ['wsgi']
  61. }
  62. })
  63. def remove_directory_recursive(dirname):
  64. '''remove a directory recursively'''
  65. app.logger.info('Removing directory ' + dirname)
  66. if not os.path.exists(dirname):
  67. return
  68. f = pathlib.Path(dirname)
  69. if f.is_file():
  70. f.unlink()
  71. else:
  72. shutil.rmtree(f, True)
  73. def create_directory(dir_path):
  74. '''create a directory, don't fail if it exists'''
  75. app.logger.info('Creating ' + dir_path)
  76. pathlib.Path(dir_path).mkdir(parents=True, exist_ok=True)
  77. def run_build(task, tmpdir, outdir, logpath):
  78. '''run a build with parameters from task'''
  79. remove_directory_recursive(tmpdir_parent)
  80. create_directory(tmpdir)
  81. if not os.path.isfile(os.path.join(outdir, 'extra_hwdef.dat')):
  82. app.logger.error('Build aborted, missing extra_hwdef.dat')
  83. app.logger.info('Appending to build.log')
  84. with open(logpath, 'a') as log:
  85. # setup PATH to point at our compiler
  86. env = os.environ.copy()
  87. bindir1 = os.path.abspath(os.path.join(appdir, "..", "bin"))
  88. bindir2 = os.path.abspath(os.path.join(appdir, "..", "gcc", "bin"))
  89. cachedir = os.path.abspath(os.path.join(appdir, "..", "cache"))
  90. env["PATH"] = bindir1 + ":" + bindir2 + ":" + env["PATH"]
  91. env['CCACHE_DIR'] = cachedir
  92. app.logger.info('Running waf configure')
  93. subprocess.run(['python3', './waf', 'configure',
  94. '--board', task['board'],
  95. '--out', tmpdir,
  96. '--extra-hwdef', task['extra_hwdef']],
  97. cwd = task['sourcedir'],
  98. env=env,
  99. stdout=log, stderr=log)
  100. app.logger.info('Running clean')
  101. subprocess.run(['python3', './waf', 'clean'],
  102. cwd = task['sourcedir'],
  103. env=env,
  104. stdout=log, stderr=log)
  105. app.logger.info('Running build')
  106. subprocess.run(['python3', './waf', task['vehicle']],
  107. cwd = task['sourcedir'],
  108. env=env,
  109. stdout=log, stderr=log)
  110. def sort_json_files(reverse=False):
  111. json_files = list(filter(os.path.isfile,
  112. glob.glob(os.path.join(outdir_parent,
  113. '*', 'q.json'))))
  114. json_files.sort(key=lambda x: os.path.getmtime(x), reverse=reverse)
  115. return json_files
  116. def check_queue():
  117. '''thread to continuously run queued builds'''
  118. queue_lock.acquire()
  119. json_files = sort_json_files()
  120. queue_lock.release()
  121. if len(json_files) == 0:
  122. return
  123. # remove multiple build requests from same ip address (keep newest)
  124. queue_lock.acquire()
  125. ip_list = []
  126. for f in json_files:
  127. file = json.loads(open(f).read())
  128. ip_list.append(file['ip'])
  129. seen = set()
  130. ip_list.reverse()
  131. for index, value in enumerate(ip_list):
  132. if value in seen:
  133. file = json.loads(open(json_files[-index-1]).read())
  134. outdir_to_delete = os.path.join(outdir_parent, file['token'])
  135. remove_directory_recursive(outdir_to_delete)
  136. else:
  137. seen.add(value)
  138. queue_lock.release()
  139. if len(json_files) == 0:
  140. return
  141. # open oldest q.json file
  142. json_files = sort_json_files()
  143. taskfile = json_files[0]
  144. app.logger.info('Opening ' + taskfile)
  145. task = json.loads(open(taskfile).read())
  146. app.logger.info('Removing ' + taskfile)
  147. os.remove(taskfile)
  148. outdir = os.path.join(outdir_parent, task['token'])
  149. tmpdir = os.path.join(tmpdir_parent, task['token'])
  150. logpath = os.path.abspath(os.path.join(outdir, 'build.log'))
  151. app.logger.info("LOGPATH: %s" % logpath)
  152. try:
  153. # run build and rename build directory
  154. run_build(task, tmpdir, outdir, logpath)
  155. app.logger.info('Copying build files from %s to %s',
  156. os.path.join(tmpdir, task['board']),
  157. outdir)
  158. copy_tree(os.path.join(tmpdir, task['board'], 'bin'), outdir)
  159. app.logger.info('Build successful!')
  160. remove_directory_recursive(tmpdir)
  161. # remove extra_hwdef.dat and q.json
  162. app.logger.info('Removing ' +
  163. os.path.join(outdir, 'extra_hwdef.dat'))
  164. os.remove(os.path.join(outdir, 'extra_hwdef.dat'))
  165. except Exception as ex:
  166. app.logger.info(ex)('Build failed: ', ex)
  167. pass
  168. open(logpath,'a').write("\nBUILD_FINISHED\n")
  169. def file_age(fname):
  170. '''return file age in seconds'''
  171. return time.time() - os.stat(fname).st_mtime
  172. def remove_old_builds():
  173. '''as a cleanup, remove any builds older than 24H'''
  174. for f in os.listdir(outdir_parent):
  175. bdir = os.path.join(outdir_parent, f)
  176. if os.path.isdir(bdir) and file_age(bdir) > 24 * 60 * 60:
  177. remove_directory_recursive(bdir)
  178. time.sleep(5)
  179. def queue_thread():
  180. while True:
  181. try:
  182. check_queue()
  183. remove_old_builds()
  184. except Exception as ex:
  185. app.logger.error(ex)('Failed queue: ', ex)
  186. pass
  187. def get_build_status():
  188. '''return build status tuple list
  189. returns tuples of form (status,age,board,vehicle,genlink)
  190. '''
  191. ret = []
  192. # get list of directories
  193. blist = []
  194. for b in os.listdir(outdir_parent):
  195. if os.path.isdir(os.path.join(outdir_parent,b)):
  196. blist.append(b)
  197. blist.sort(key=lambda x: os.path.getmtime(os.path.join(outdir_parent,x)), reverse=True)
  198. for b in blist:
  199. a = b.split(':')
  200. if len(a) < 2:
  201. continue
  202. vehicle = a[0].capitalize()
  203. board = a[1]
  204. link = "/view?token=%s" % b
  205. age_min = int(file_age(os.path.join(outdir_parent,b))/60.0)
  206. age_str = "%u:%02u" % ((age_min // 60), age_min % 60)
  207. feature_file = os.path.join(outdir_parent, b, 'selected_features.json')
  208. app.logger.info('Opening ' + feature_file)
  209. selected_features_dict = json.loads(open(feature_file).read())
  210. selected_features = selected_features_dict['selected_features']
  211. git_hash_short = selected_features_dict['git_hash_short']
  212. features = ''
  213. for feature in selected_features:
  214. if features == '':
  215. features = features + feature
  216. else:
  217. features = features + ", " + feature
  218. if os.path.exists(os.path.join(outdir_parent,b,'q.json')):
  219. status = "Pending"
  220. elif not os.path.exists(os.path.join(outdir_parent,b,'build.log')):
  221. status = "Error"
  222. else:
  223. build = open(os.path.join(outdir_parent,b,'build.log')).read()
  224. if build.find("'%s' finished successfully" % vehicle.lower()) != -1:
  225. status = "Finished"
  226. elif build.find('The configuration failed') != -1 or build.find('Build failed') != -1:
  227. status = "Failed"
  228. elif build.find('BUILD_FINISHED') == -1:
  229. status = "Running"
  230. else:
  231. status = "Failed"
  232. ret.append((status,age_str,board,vehicle,link,features,git_hash_short))
  233. return ret
  234. def create_status():
  235. '''create status.html'''
  236. build_status = get_build_status()
  237. tmpfile = os.path.join(outdir_parent, "status.tmp")
  238. statusfile = os.path.join(outdir_parent, "status.html")
  239. f = open(tmpfile, "w")
  240. app2 = Flask("status")
  241. with app2.app_context():
  242. f.write(render_template_string(open(os.path.join(appdir, 'templates', 'status.html')).read(),
  243. build_status=build_status))
  244. f.close()
  245. os.replace(tmpfile, statusfile)
  246. def status_thread():
  247. while True:
  248. try:
  249. create_status()
  250. except Exception as ex:
  251. app.logger.info(ex)
  252. pass
  253. time.sleep(3)
  254. def update_source():
  255. '''update submodules and ardupilot git tree'''
  256. app.logger.info('Fetching ardupilot upstream')
  257. subprocess.run(['git', 'fetch', 'upstream'],
  258. cwd=sourcedir)
  259. app.logger.info('Updating ardupilot git tree')
  260. subprocess.run(['git', 'reset', '--hard',
  261. 'upstream/master'],
  262. cwd=sourcedir)
  263. app.logger.info('Updating submodules')
  264. subprocess.run(['git', 'submodule',
  265. 'update', '--recursive',
  266. '--force', '--init'],
  267. cwd=sourcedir)
  268. import optparse
  269. parser = optparse.OptionParser("app.py")
  270. parser.add_option("", "--basedir", type="string",
  271. default=os.path.abspath(os.path.join(os.path.dirname(__file__),"..","base")),
  272. help="base directory")
  273. cmd_opts, cmd_args = parser.parse_args()
  274. # define directories
  275. basedir = os.path.abspath(cmd_opts.basedir)
  276. sourcedir = os.path.abspath(os.path.join(basedir, 'ardupilot'))
  277. outdir_parent = os.path.join(basedir, 'builds')
  278. tmpdir_parent = os.path.join(basedir, 'tmp')
  279. app = Flask(__name__, template_folder='templates')
  280. if not os.path.isdir(outdir_parent):
  281. create_directory(outdir_parent)
  282. try:
  283. lock_file = open(os.path.join(basedir, "queue.lck"), "w")
  284. fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
  285. app.logger.info("Got queue lock")
  286. # we only want one set of threads
  287. thread = Thread(target=queue_thread, args=())
  288. thread.daemon = True
  289. thread.start()
  290. status_thread = Thread(target=status_thread, args=())
  291. status_thread.daemon = True
  292. status_thread.start()
  293. except IOError:
  294. app.logger.info("No queue lock")
  295. @app.route('/generate', methods=['GET', 'POST'])
  296. def generate():
  297. try:
  298. update_source()
  299. # fetch features from user input
  300. extra_hwdef = []
  301. feature_list = []
  302. selected_features = []
  303. app.logger.info('Fetching features from user input')
  304. for (label, define, text, default, category) in BUILD_OPTIONS:
  305. if label not in request.form:
  306. continue
  307. extra_hwdef.append(request.form[label])
  308. if request.form[label][-1] == '1':
  309. feature_list.append(text)
  310. selected_features.append(label)
  311. undefine = 'undef ' + define
  312. extra_hwdef.insert(0, undefine)
  313. extra_hwdef = '\n'.join(extra_hwdef)
  314. spaces = '\n'
  315. feature_list = spaces.join(feature_list)
  316. selected_features_dict = {}
  317. selected_features_dict['selected_features'] = selected_features
  318. queue_lock.acquire()
  319. # create extra_hwdef.dat file and obtain md5sum
  320. app.logger.info('Creating ' +
  321. os.path.join(outdir_parent, 'extra_hwdef.dat'))
  322. file = open(os.path.join(outdir_parent, 'extra_hwdef.dat'), 'w')
  323. app.logger.info('Writing\n' + extra_hwdef)
  324. file.write(extra_hwdef)
  325. file.close()
  326. md5sum = hashlib.md5(extra_hwdef.encode('utf-8')).hexdigest()
  327. app.logger.info('Removing ' +
  328. os.path.join(outdir_parent, 'extra_hwdef.dat'))
  329. os.remove(os.path.join(outdir_parent, 'extra_hwdef.dat'))
  330. # obtain git-hash of source
  331. app.logger.info('Getting git hash')
  332. git_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'],
  333. cwd = sourcedir,
  334. encoding = 'utf-8')
  335. git_hash_short = subprocess.check_output(['git', 'rev-parse',
  336. '--short', 'HEAD'],
  337. cwd = sourcedir,
  338. encoding = 'utf-8')
  339. git_hash = git_hash[:len(git_hash)-1]
  340. app.logger.info('Git hash = ' + git_hash)
  341. selected_features_dict['git_hash_short'] = git_hash_short
  342. # create directories using concatenated token
  343. # of vehicle, board, git-hash of source, and md5sum of hwdef
  344. vehicle = request.form['vehicle']
  345. if not vehicle in VEHICLES:
  346. raise Exception("bad vehicle")
  347. board = request.form['board']
  348. if board not in get_boards()[0]:
  349. raise Exception("bad board")
  350. token = vehicle.lower() + ':' + board + ':' + git_hash + ':' + md5sum
  351. app.logger.info('token = ' + token)
  352. global outdir
  353. outdir = os.path.join(outdir_parent, token)
  354. if os.path.isdir(outdir):
  355. app.logger.info('Build already exists')
  356. else:
  357. create_directory(outdir)
  358. # create build.log
  359. build_log_info = ('Vehicle: ' + vehicle +
  360. '\nBoard: ' + board +
  361. '\nSelected Features:\n' + feature_list +
  362. '\n\nWaiting for build to start...\n\n')
  363. app.logger.info('Creating build.log')
  364. build_log = open(os.path.join(outdir, 'build.log'), 'w')
  365. build_log.write(build_log_info)
  366. build_log.close()
  367. # create hwdef.dat
  368. app.logger.info('Opening ' +
  369. os.path.join(outdir, 'extra_hwdef.dat'))
  370. file = open(os.path.join(outdir, 'extra_hwdef.dat'),'w')
  371. app.logger.info('Writing\n' + extra_hwdef)
  372. file.write(extra_hwdef)
  373. file.close()
  374. # fill dictionary of variables and create json file
  375. task = {}
  376. task['token'] = token
  377. task['sourcedir'] = sourcedir
  378. task['extra_hwdef'] = os.path.join(outdir, 'extra_hwdef.dat')
  379. task['vehicle'] = vehicle.lower()
  380. task['board'] = board
  381. task['ip'] = request.remote_addr
  382. app.logger.info('Opening ' + os.path.join(outdir, 'q.json'))
  383. jfile = open(os.path.join(outdir, 'q.json'), 'w')
  384. app.logger.info('Writing task file to ' +
  385. os.path.join(outdir, 'q.json'))
  386. jfile.write(json.dumps(task, separators=(',\n', ': ')))
  387. jfile.close()
  388. # create selected_features.dat for status table
  389. feature_file = open(os.path.join(outdir, 'selected_features.json'), 'w')
  390. app.logger.info('Writing\n' + os.path.join(outdir, 'selected_features.json'))
  391. feature_file.write(json.dumps(selected_features_dict))
  392. feature_file.close()
  393. queue_lock.release()
  394. base_url = request.url_root
  395. app.logger.info(base_url)
  396. app.logger.info('Rendering generate.html')
  397. return render_template('generate.html', token=token)
  398. except Exception as ex:
  399. app.logger.error(ex)
  400. return render_template('generate.html', error='Error occured: ', ex=ex)
  401. @app.route('/view', methods=['GET'])
  402. def view():
  403. '''view a build from status'''
  404. token=request.args['token']
  405. app.logger.info("viewing %s" % token)
  406. return render_template('generate.html', token=token)
  407. def get_build_options():
  408. return BUILD_OPTIONS
  409. def get_vehicles():
  410. return (VEHICLES, default_vehicle)
  411. @app.route('/')
  412. def home():
  413. app.logger.info('Rendering index.html')
  414. return render_template('index.html',
  415. get_boards=get_boards,
  416. get_vehicles=get_vehicles,
  417. get_build_options=get_build_options)
  418. @app.route("/builds/<path:name>")
  419. def download_file(name):
  420. app.logger.info('Downloading %s' % name)
  421. return send_from_directory(os.path.join(basedir,'builds'), name, as_attachment=False)
  422. if __name__ == '__main__':
  423. app.run()