app.py 18 KB

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