app.py 20 KB

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