progress_updater.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. import re
  2. import os
  3. import logging
  4. from utils import TaskRunner
  5. from pathlib import Path
  6. from .manager import (
  7. BuildManager as bm,
  8. BuildState
  9. )
  10. import json
  11. import time
  12. class BuildProgressUpdater:
  13. """
  14. Class for updating the progress of all builds.
  15. This class ensures that the progress of all builds is
  16. updated periodically. It operates in a singleton pattern
  17. to ensure only one instance manages the updates.
  18. """
  19. __singleton = None
  20. def __init__(self):
  21. """
  22. Initialises the BuildProgressUpdater instance.
  23. This uses the BuildManager singleton, so ensure that BuildManager is
  24. initialised before creating a BuildProgressUpdater instance.
  25. Raises:
  26. RuntimeError: If BuildManager is not initialized or
  27. if another instance of BuildProgressUpdater has already
  28. been initialised.
  29. """
  30. if not bm.get_singleton():
  31. raise RuntimeError("BuildManager should be initialised first")
  32. if BuildProgressUpdater.__singleton:
  33. raise RuntimeError("BuildProgressUpdater must be a singleton.")
  34. self.__ensure_status_json()
  35. # Set up a periodic task to update build progress every 3 seconds
  36. # TaskRunner will handle scheduling and running the task.
  37. tasks = (
  38. (self.__update_build_progress_all, 3),
  39. )
  40. self.__runner = TaskRunner(tasks=tasks)
  41. self.logger = logging.getLogger(__name__)
  42. BuildProgressUpdater.__singleton = self
  43. def start(self) -> None:
  44. """
  45. Start BuildProgressUpdater.
  46. """
  47. self.logger.info("Starting BuildProgressUpdater.")
  48. self.__runner.start()
  49. def __calc_running_build_progress_percent(self, build_id: str) -> int:
  50. """
  51. Calculate the progress percentage of a running build.
  52. This method analyses the build log to determine the current completion
  53. percentage by parsing the build steps from the log file.
  54. Parameters:
  55. build_id (str): The unique ID of the build for which progress is
  56. calculated.
  57. Returns:
  58. int: The calculated build progress percentage (0 to 100).
  59. Raises:
  60. ValueError: If no build information is found for the provided
  61. build ID.
  62. """
  63. build_info = bm.get_singleton().get_build_info(build_id=build_id)
  64. if build_info is None:
  65. raise ValueError(f"No build found with ID {build_id}")
  66. if build_info.progress.state != BuildState.RUNNING:
  67. raise RuntimeError(
  68. "This method should only be called for running builds."
  69. )
  70. # Construct path to the build's log file
  71. log_file_path = bm.get_singleton().get_build_log_path(build_id)
  72. self.logger.debug(f"Opening log file: {log_file_path}")
  73. try:
  74. # Read the log content
  75. with open(log_file_path, encoding='utf-8') as f:
  76. build_log = f.read()
  77. except FileNotFoundError:
  78. self.logger.error(
  79. f"Log file not found for RUNNING build with ID: {build_id}"
  80. )
  81. return build_info.progress.percent
  82. # Regular expression to extract the build progress steps
  83. compiled_regex = re.compile(r'(\[\D*(\d+)\D*\/\D*(\d+)\D*\])')
  84. self.logger.debug(f"Regex pattern: {compiled_regex}")
  85. all_matches = compiled_regex.findall(build_log)
  86. self.logger.debug(f"Log matches: {all_matches}")
  87. # If no matches are found, return a default progress value of 0
  88. if len(all_matches) < 1:
  89. return 0
  90. completed_steps, total_steps = all_matches[-1][1:]
  91. self.logger.debug(
  92. f"Completed steps: {completed_steps},"
  93. f"Total steps: {total_steps}"
  94. )
  95. # Handle initial compilation/linking steps (minor weight)
  96. if int(total_steps) < 20:
  97. return 1
  98. # Handle building the OS phase (4% weight)
  99. if int(total_steps) < 200:
  100. return (int(completed_steps) * 4 // int(total_steps)) + 1
  101. # Major build phase (95% weight)
  102. return (int(completed_steps) * 95 // int(total_steps)) + 5
  103. def __refresh_running_build_state(self, build_id: str) -> BuildState:
  104. """
  105. Refresh the state of a running build.
  106. This method analyses the build log to determine the build has
  107. concluded. If yes, it detects the success of a build by finding
  108. the success message in the log.
  109. Parameters:
  110. build_id (str): The unique ID of the build for which progress is
  111. calculated.
  112. Returns:
  113. BuildSate: The current build state based on the log.
  114. Raises:
  115. ValueError: If no build information is found for the provided
  116. build ID.
  117. """
  118. build_info = bm.get_singleton().get_build_info(build_id=build_id)
  119. if build_info is None:
  120. raise ValueError(f"No build found with ID {build_id}")
  121. if build_info.progress.state != BuildState.RUNNING:
  122. raise RuntimeError(
  123. "This method should only be called for running builds."
  124. )
  125. # Builder ships the archive post completion
  126. # This is irrespective of SUCCESS or FAILURE
  127. if not os.path.exists(
  128. bm.get_singleton().get_build_archive_path(build_id)
  129. ):
  130. return BuildState.RUNNING
  131. log_file_path = bm.get_singleton().get_build_log_path(build_id)
  132. try:
  133. # Read the log content
  134. with open(log_file_path, encoding='utf-8') as f:
  135. build_log = f.read()
  136. except FileNotFoundError:
  137. self.logger.error(
  138. f"Log file not found for RUNNING build with ID: {build_id}"
  139. )
  140. return BuildState.ERROR
  141. # Build has finished, check if it succeeded or failed
  142. flash_summary_pos = build_log.find("Total Flash Used")
  143. if flash_summary_pos == -1:
  144. return BuildState.FAILURE
  145. else:
  146. return BuildState.SUCCESS
  147. def __update_build_percent(self, build_id: str) -> None:
  148. """
  149. Update the progress percentage of a given build.
  150. """
  151. build_info = bm.get_singleton().get_build_info(build_id=build_id)
  152. if build_info is None:
  153. raise ValueError(f"No build found with ID {build_id}")
  154. current_state = build_info.progress.state
  155. current_percent = build_info.progress.percent
  156. new_percent = current_percent
  157. self.logger.debug(
  158. f"Build id: {build_id}, "
  159. f"Current state: {current_state}, "
  160. f"Current percentage: {current_percent}, "
  161. )
  162. if current_state == BuildState.PENDING:
  163. # Keep existing percentage
  164. pass
  165. elif current_state == BuildState.RUNNING:
  166. new_percent = self.__calc_running_build_progress_percent(build_id)
  167. elif current_state == BuildState.SUCCESS:
  168. new_percent = 100
  169. elif current_state == BuildState.FAILURE:
  170. # Keep existing percentage
  171. pass
  172. elif current_state == BuildState.ERROR:
  173. # Keep existing percentage
  174. pass
  175. else:
  176. raise Exception("Unhandled BuildState.")
  177. self.logger.debug(
  178. f"Build id: {build_id}, "
  179. f"New percentage: {new_percent}, "
  180. )
  181. if new_percent != current_percent:
  182. bm.get_singleton().update_build_progress_percent(
  183. build_id=build_id,
  184. percent=new_percent
  185. )
  186. def __update_build_state(self, build_id: str) -> None:
  187. """
  188. Update the state of a given build.
  189. """
  190. build_info = bm.get_singleton().get_build_info(build_id=build_id)
  191. if build_info is None:
  192. raise ValueError(f"No build found with ID {build_id}")
  193. current_state = build_info.progress.state
  194. new_state = current_state
  195. self.logger.debug(
  196. f"Build id: {build_id}, "
  197. f"Current state: {current_state.name}, "
  198. )
  199. log_file_path = bm.get_singleton().get_build_log_path(build_id)
  200. if current_state == BuildState.PENDING:
  201. # Builder creates log file when it starts
  202. # running a build
  203. if os.path.exists(log_file_path):
  204. new_state = BuildState.RUNNING
  205. elif current_state == BuildState.RUNNING:
  206. new_state = self.__refresh_running_build_state(build_id)
  207. elif current_state == BuildState.SUCCESS:
  208. # SUCCESS is a conclusive state
  209. pass
  210. elif current_state == BuildState.FAILURE:
  211. # FAILURE is a conclusive state
  212. pass
  213. elif current_state == BuildState.ERROR:
  214. # ERROR is a conclusive state
  215. pass
  216. else:
  217. raise Exception("Unhandled BuildState.")
  218. self.logger.debug(
  219. f"Build id: {build_id}, "
  220. f"New state: {new_state.name}, "
  221. )
  222. if current_state != new_state:
  223. bm.get_singleton().update_build_progress_state(
  224. build_id=build_id,
  225. new_state=new_state,
  226. )
  227. def __update_build_progress_all(self) -> None:
  228. """
  229. Update progress for all builds.
  230. This method will iterate through all builds, calculate their
  231. progress, and update the build manager with the latest progress state
  232. and percentage.
  233. """
  234. for build_id in bm.get_singleton().get_all_build_ids():
  235. self.__update_build_state(build_id)
  236. self.__update_build_percent(build_id)
  237. # Generate status.json after updating build progress.
  238. self.__generate_status_json()
  239. def get_status_json_path(self) -> str:
  240. """
  241. Path to status.json file.
  242. """
  243. return os.path.join(
  244. bm.get_singleton().get_outdir(),
  245. 'status.json'
  246. )
  247. def __ensure_status_json(self) -> None:
  248. """
  249. Ensures status.json exists and is a valid JSON file.
  250. """
  251. p = Path(self.get_status_json_path())
  252. if not p.exists():
  253. # Ensure parent directory exists
  254. Path.mkdir(p.parent, parents=True, exist_ok=True)
  255. # write empty json dict
  256. with open(p, 'w') as f:
  257. f.write('{}')
  258. def __generate_status_json(self) -> None:
  259. """
  260. Rewrite status.json file.
  261. """
  262. all_build_ids_sorted = sorted(
  263. bm.get_singleton().get_all_build_ids(),
  264. key=lambda x: bm.get_singleton().get_build_info(x).time_created,
  265. reverse=True
  266. )
  267. self.logger.debug(f"All build ids sorted: {all_build_ids_sorted}")
  268. # To-do: fix status.json structure,
  269. # write a list instead of a dict to the file
  270. builds_dict = {}
  271. for build_id in all_build_ids_sorted:
  272. build_info = bm.get_singleton().get_build_info(build_id)
  273. build_age_min = int(time.time() - build_info.time_created) // 60
  274. bi_json = {
  275. 'vehicle': build_info.vehicle.capitalize(),
  276. 'board': build_info.board,
  277. 'git_hash_short': build_info.git_hash[:8],
  278. 'features': ', '.join(build_info.selected_features),
  279. 'status': build_info.progress.state.name,
  280. 'progress': build_info.progress.percent,
  281. 'age': "%u:%02u" % ((build_age_min // 60), build_age_min % 60)
  282. }
  283. self.logger.debug(f"Build info json: {bi_json}")
  284. builds_dict[build_id] = bi_json
  285. self.logger.debug(f"Builds dict: {builds_dict}")
  286. with open(self.get_status_json_path(), 'w') as f:
  287. f.write(json.dumps(builds_dict))
  288. @staticmethod
  289. def get_singleton() -> "BuildProgressUpdater":
  290. """
  291. Get the singleton instance of BuildProgressUpdater.
  292. Returns:
  293. BuildProgressUpdater: The singleton instance of this class.
  294. """
  295. return BuildProgressUpdater.__singleton