app.py 18 KB

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