app.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. #!/usr/bin/env python3
  2. import os
  3. from flask import Flask, render_template, request, send_from_directory, jsonify, redirect
  4. from threading import Thread
  5. import sys
  6. import requests
  7. import signal
  8. from logging.config import dictConfig
  9. dictConfig({
  10. 'version': 1,
  11. 'formatters': {'default': {
  12. 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
  13. }},
  14. 'handlers': {'wsgi': {
  15. 'class': 'logging.StreamHandler',
  16. 'stream': 'ext://flask.logging.wsgi_errors_stream',
  17. 'formatter': 'default'
  18. }},
  19. 'root': {
  20. 'level': os.getenv('CBS_LOG_LEVEL', default='INFO'),
  21. 'handlers': ['wsgi']
  22. }
  23. })
  24. # let app.py know about the modules in the parent directory
  25. sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
  26. import ap_git
  27. import metadata_manager
  28. import build_manager
  29. from builder import Builder
  30. from utils.ratelimiter import RateLimitExceededException
  31. # run at lower priority
  32. os.nice(20)
  33. import optparse
  34. parser = optparse.OptionParser("app.py")
  35. parser.add_option("", "--basedir", type="string",
  36. default=os.getenv(
  37. key="CBS_BASEDIR",
  38. default=os.path.abspath(os.path.join(os.path.dirname(__file__),"..","base"))
  39. ),
  40. help="base directory")
  41. cmd_opts, cmd_args = parser.parse_args()
  42. # define directories
  43. basedir = os.path.abspath(cmd_opts.basedir)
  44. sourcedir = os.path.join(basedir, 'ardupilot')
  45. outdir_parent = os.path.join(basedir, 'artifacts')
  46. workdir_parent = os.path.join(basedir, 'workdir')
  47. appdir = os.path.dirname(__file__)
  48. builds_dict = {}
  49. REMOTES = None
  50. repo = ap_git.GitRepo.clone_if_needed(
  51. source="https://github.com/ardupilot/ardupilot.git",
  52. dest=sourcedir,
  53. recurse_submodules=True,
  54. )
  55. vehicles_manager = metadata_manager.VehiclesManager()
  56. ap_src_metadata_fetcher = metadata_manager.APSourceMetadataFetcher(
  57. ap_repo=repo,
  58. caching_enabled=True,
  59. redis_host=os.getenv('CBS_REDIS_HOST', default='localhost'),
  60. redis_port=os.getenv('CBS_REDIS_PORT', default='6379'),
  61. )
  62. versions_fetcher = metadata_manager.VersionsFetcher(
  63. remotes_json_path=os.path.join(basedir, 'configs', 'remotes.json'),
  64. ap_repo=repo
  65. )
  66. manager = build_manager.BuildManager(
  67. outdir=outdir_parent,
  68. redis_host=os.getenv('CBS_REDIS_HOST', default='localhost'),
  69. redis_port=os.getenv('CBS_REDIS_PORT', default='6379')
  70. )
  71. cleaner = build_manager.BuildArtifactsCleaner()
  72. progress_updater = build_manager.BuildProgressUpdater()
  73. versions_fetcher.start()
  74. cleaner.start()
  75. progress_updater.start()
  76. # Initialize builder if enabled
  77. builder = None
  78. builder_thread = None
  79. if os.getenv('CBS_ENABLE_INBUILT_BUILDER', default='1') == '1':
  80. builder = Builder(
  81. workdir=workdir_parent,
  82. source_repo=repo
  83. )
  84. builder_thread = Thread(
  85. target=builder.run,
  86. daemon=True
  87. )
  88. builder_thread.start()
  89. app = Flask(__name__, template_folder='templates')
  90. # Setup graceful shutdown handler
  91. def shutdown_handler(signum=None, frame=None):
  92. """
  93. Gracefully shutdown all background services.
  94. """
  95. app.logger.info("Shutting down application gracefully...")
  96. # Stop all TaskRunner instances
  97. versions_fetcher.stop()
  98. cleaner.stop()
  99. progress_updater.stop()
  100. # Request builder shutdown if it's running
  101. if builder is not None:
  102. builder.shutdown()
  103. app.logger.info("All background services stopped successfully.")
  104. sys.exit(0)
  105. # Register signal handlers for graceful shutdown
  106. signal.signal(signal.SIGINT, shutdown_handler)
  107. signal.signal(signal.SIGTERM, shutdown_handler)
  108. versions_fetcher.reload_remotes_json()
  109. app.logger.info('Python version is: %s' % sys.version)
  110. def get_auth_token():
  111. try:
  112. # try to read the secret token from the file
  113. with open(os.path.join(basedir, 'secrets', 'reload_token'), 'r') as file:
  114. token = file.read().strip()
  115. return token
  116. except (FileNotFoundError, PermissionError):
  117. app.logger.error("Couldn't open token file. Checking environment for token.")
  118. # if the file does not exist, check the environment variable
  119. return os.getenv('CBS_REMOTES_RELOAD_TOKEN')
  120. @app.route('/refresh_remotes', methods=['POST'])
  121. def refresh_remotes():
  122. auth_token = get_auth_token()
  123. if auth_token is None:
  124. app.logger.error("Couldn't retrieve authorization token")
  125. return "Internal Server Error", 500
  126. token = request.get_json().get('token')
  127. if not token or token != auth_token:
  128. return "Unauthorized", 401
  129. versions_fetcher.reload_remotes_json()
  130. return "Successfully refreshed remotes", 200
  131. @app.route('/generate', methods=['GET', 'POST'])
  132. def generate():
  133. try:
  134. version = request.form['version']
  135. vehicle = request.form['vehicle']
  136. version_info = versions_fetcher.get_version_info(
  137. vehicle_id=vehicle,
  138. version_id=version
  139. )
  140. if version_info is None:
  141. raise Exception("Version invalid or not listed to be built for given vehicle")
  142. remote_name = version_info.remote_info.name
  143. commit_ref = version_info.commit_ref
  144. board = request.form['board']
  145. boards_at_commit = ap_src_metadata_fetcher.get_boards(
  146. remote=remote_name,
  147. commit_ref=commit_ref,
  148. vehicle_id=vehicle,
  149. )
  150. if board not in boards_at_commit:
  151. raise Exception("bad board")
  152. all_features = ap_src_metadata_fetcher.get_build_options_at_commit(
  153. remote=remote_name,
  154. commit_ref=commit_ref
  155. )
  156. chosen_defines = {
  157. feature.define
  158. for feature in all_features
  159. if request.form.get(feature.label) == "1"
  160. }
  161. git_hash = repo.commit_id_for_remote_ref(
  162. remote=remote_name,
  163. commit_ref=commit_ref
  164. )
  165. build_info = build_manager.BuildInfo(
  166. vehicle_id=vehicle,
  167. remote_info=version_info.remote_info,
  168. git_hash=git_hash,
  169. board=board,
  170. selected_features=chosen_defines
  171. )
  172. forwarded_for = request.headers.get('X-Forwarded-For', None)
  173. if forwarded_for:
  174. client_ip = forwarded_for.split(',')[0].strip()
  175. else:
  176. client_ip = request.remote_addr
  177. build_id = manager.submit_build(
  178. build_info=build_info,
  179. client_ip=client_ip,
  180. )
  181. app.logger.info('Redirecting to /viewlog')
  182. return redirect('/viewlog/'+build_id)
  183. except RateLimitExceededException as ex:
  184. app.logger.warning(f"Rate limit exceeded for client: {request.remote_addr}")
  185. return render_template('error.html', ex=ex), 429
  186. except Exception as ex:
  187. app.logger.error(ex)
  188. return render_template('error.html', ex=ex)
  189. @app.route('/add_build')
  190. def add_build():
  191. app.logger.info('Rendering add_build.html')
  192. return render_template('add_build.html')
  193. def filter_build_options_by_category(build_options, category):
  194. return sorted([f for f in build_options if f.category == category], key=lambda x: x.description.lower())
  195. def parse_build_categories(build_options):
  196. return sorted(list(set([f.category for f in build_options])))
  197. @app.route('/', defaults={'token': None}, methods=['GET'])
  198. @app.route('/viewlog/<token>', methods=['GET'])
  199. def home(token):
  200. if token:
  201. app.logger.info("Showing log for build id " + token)
  202. app.logger.info('Rendering index.html')
  203. return render_template('index.html', token=token)
  204. @app.route("/builds/<string:build_id>/artifacts/<path:name>")
  205. def download_file(build_id, name):
  206. path = os.path.join(
  207. basedir,
  208. 'artifacts',
  209. build_id,
  210. )
  211. app.logger.info('Downloading %s/%s' % (path, name))
  212. return send_from_directory(path, name, as_attachment=False)
  213. @app.route("/boards_and_features/<string:vehicle_id>/<string:version_id>", methods=['GET'])
  214. def boards_and_features(vehicle_id, version_id):
  215. version_info = versions_fetcher.get_version_info(
  216. vehicle_id=vehicle_id,
  217. version_id=version_id
  218. )
  219. if version_info is None:
  220. return "Bad request. Version not allowed to build for the vehicle.", 400
  221. remote_name = version_info.remote_info.name
  222. commit_reference = version_info.commit_ref
  223. app.logger.info('Board list and build options requested for %s %s' % (vehicle_id, version_id))
  224. # getting board list for the branch
  225. with repo.get_checkout_lock():
  226. boards = ap_src_metadata_fetcher.get_boards(
  227. remote=remote_name,
  228. commit_ref=commit_reference,
  229. vehicle_id=vehicle_id,
  230. )
  231. options = ap_src_metadata_fetcher.get_build_options_at_commit(
  232. remote=remote_name,
  233. commit_ref=commit_reference
  234. ) # this is a list of Feature() objects defined in build_options.py
  235. # parse the set of categories from these objects
  236. categories = parse_build_categories(options)
  237. features = []
  238. for category in categories:
  239. filtered_options = filter_build_options_by_category(options, category)
  240. category_options = [] # options belonging to a given category
  241. for option in filtered_options:
  242. category_options.append({
  243. 'label' : option.label,
  244. 'description' : option.description,
  245. 'default' : option.default,
  246. 'define' : option.define,
  247. 'dependency' : option.dependency,
  248. })
  249. features.append({
  250. 'name' : category,
  251. 'options' : category_options,
  252. })
  253. # creating result dictionary
  254. result = {
  255. 'boards' : boards,
  256. 'default_board' : boards[0],
  257. 'features' : features,
  258. }
  259. # return jsonified result dict
  260. return jsonify(result)
  261. @app.route("/get_versions/<string:vehicle_id>", methods=['GET'])
  262. def get_versions(vehicle_id):
  263. versions = list()
  264. for version_info in versions_fetcher.get_versions_for_vehicle(vehicle_id=vehicle_id):
  265. if version_info.release_type == "latest":
  266. title = f"Latest ({version_info.remote_info.name})"
  267. else:
  268. title = f"{version_info.release_type} {version_info.version_number} ({version_info.remote_info.name})"
  269. versions.append({
  270. "title": title,
  271. "id": version_info.version_id,
  272. })
  273. return jsonify(sorted(versions, key=lambda x: x['title']))
  274. @app.route("/get_vehicles")
  275. def get_vehicles():
  276. vehicles = [
  277. {"id": vehicle.id, "name": vehicle.name}
  278. for vehicle in vehicles_manager.get_all_vehicles()
  279. ]
  280. return jsonify(sorted(vehicles, key=lambda x: x['id']))
  281. @app.route("/get_defaults/<string:vehicle_id>/<string:version_id>/<string:board_name>", methods = ['GET'])
  282. def get_deafults(vehicle_id, version_id, board_name):
  283. vehicle = vehicles_manager.get_vehicle_by_id(vehicle_id)
  284. if vehicle is None:
  285. return "Invalid vehicle ID", 400
  286. # Heli is built on copter boards with -heli suffix
  287. if vehicle_id == "heli":
  288. board_name += "-heli"
  289. version_info = versions_fetcher.get_version_info(
  290. vehicle_id=vehicle_id,
  291. version_id=version_id
  292. )
  293. if version_info is None:
  294. return "Bad request. Version is not allowed for builds for the %s." % vehicle.name, 400
  295. artifacts_dir = version_info.ap_build_artifacts_url
  296. if artifacts_dir is None:
  297. return "Couldn't find artifacts for requested release/branch/commit on ardupilot server", 404
  298. url_to_features_txt = artifacts_dir + '/' + board_name + '/features.txt'
  299. response = requests.get(url_to_features_txt, timeout=30)
  300. if not response.status_code == 200:
  301. return ("Could not retrieve features.txt for given vehicle, version and board combination (Status Code: %d, url: %s)" % (response.status_code, url_to_features_txt), response.status_code)
  302. # split response by new line character to get a list of defines
  303. result = response.text.split('\n')
  304. # omit the last two elements as they are always blank
  305. return jsonify(result[:-2])
  306. @app.route('/builds', methods=['GET'])
  307. def get_all_builds():
  308. all_build_ids = manager.get_all_build_ids()
  309. all_build_info = [
  310. {
  311. **manager.get_build_info(build_id).to_dict(),
  312. 'build_id': build_id
  313. }
  314. for build_id in all_build_ids
  315. ]
  316. all_build_info_sorted = sorted(
  317. all_build_info,
  318. key=lambda x: x['time_created'],
  319. reverse=True,
  320. )
  321. return (
  322. jsonify(all_build_info_sorted),
  323. 200
  324. )
  325. @app.route('/builds/<string:build_id>', methods=['GET'])
  326. def get_build_by_id(build_id):
  327. if not manager.build_exists(build_id):
  328. response = {
  329. 'error': f'build with id {build_id} does not exist.',
  330. }
  331. return jsonify(response), 200
  332. response = {
  333. **manager.get_build_info(build_id).to_dict(),
  334. 'build_id': build_id
  335. }
  336. return jsonify(response), 200
  337. if __name__ == '__main__':
  338. app.run()