progress_updater.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  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. success_message_pos = build_log.find(
  143. f"'{build_info.vehicle.lower()}' finished successfully"
  144. )
  145. if success_message_pos == -1:
  146. return BuildState.FAILURE
  147. else:
  148. return BuildState.SUCCESS
  149. def __update_build_percent(self, build_id: str) -> None:
  150. """
  151. Update the progress percentage of a given build.
  152. """
  153. build_info = bm.get_singleton().get_build_info(build_id=build_id)
  154. if build_info is None:
  155. raise ValueError(f"No build found with ID {build_id}")
  156. current_state = build_info.progress.state
  157. current_percent = build_info.progress.percent
  158. new_percent = current_percent
  159. self.logger.debug(
  160. f"Build id: {build_id}, "
  161. f"Current state: {current_state}, "
  162. f"Current percentage: {current_percent}, "
  163. )
  164. if current_state == BuildState.PENDING:
  165. # Keep existing percentage
  166. pass
  167. elif current_state == BuildState.RUNNING:
  168. new_percent = self.__calc_running_build_progress_percent(build_id)
  169. elif current_state == BuildState.SUCCESS:
  170. new_percent = 100
  171. elif current_state == BuildState.FAILURE:
  172. # Keep existing percentage
  173. pass
  174. elif current_state == BuildState.ERROR:
  175. # Keep existing percentage
  176. pass
  177. else:
  178. raise Exception("Unhandled BuildState.")
  179. self.logger.debug(
  180. f"Build id: {build_id}, "
  181. f"New percentage: {new_percent}, "
  182. )
  183. if new_percent != current_percent:
  184. bm.get_singleton().update_build_progress_percent(
  185. build_id=build_id,
  186. percent=new_percent
  187. )
  188. def __update_build_state(self, build_id: str) -> None:
  189. """
  190. Update the state of a given build.
  191. """
  192. build_info = bm.get_singleton().get_build_info(build_id=build_id)
  193. if build_info is None:
  194. raise ValueError(f"No build found with ID {build_id}")
  195. current_state = build_info.progress.state
  196. new_state = current_state
  197. self.logger.debug(
  198. f"Build id: {build_id}, "
  199. f"Current state: {current_state.name}, "
  200. )
  201. log_file_path = bm.get_singleton().get_build_log_path(build_id)
  202. if current_state == BuildState.PENDING:
  203. # Builder creates log file when it starts
  204. # running a build
  205. if os.path.exists(log_file_path):
  206. new_state = BuildState.RUNNING
  207. elif current_state == BuildState.RUNNING:
  208. new_state = self.__refresh_running_build_state(build_id)
  209. elif current_state == BuildState.SUCCESS:
  210. # SUCCESS is a conclusive state
  211. pass
  212. elif current_state == BuildState.FAILURE:
  213. # FAILURE is a conclusive state
  214. pass
  215. elif current_state == BuildState.ERROR:
  216. # ERROR is a conclusive state
  217. pass
  218. else:
  219. raise Exception("Unhandled BuildState.")
  220. self.logger.debug(
  221. f"Build id: {build_id}, "
  222. f"New state: {new_state.name}, "
  223. )
  224. if current_state != new_state:
  225. bm.get_singleton().update_build_progress_state(
  226. build_id=build_id,
  227. new_state=new_state,
  228. )
  229. def __update_build_progress_all(self) -> None:
  230. """
  231. Update progress for all builds.
  232. This method will iterate through all builds, calculate their
  233. progress, and update the build manager with the latest progress state
  234. and percentage.
  235. """
  236. for build_id in bm.get_singleton().get_all_build_ids():
  237. self.__update_build_state(build_id)
  238. self.__update_build_percent(build_id)
  239. # Generate status.json after updating build progress.
  240. self.__generate_status_json()
  241. def get_status_json_path(self) -> str:
  242. """
  243. Path to status.json file.
  244. """
  245. return os.path.join(
  246. bm.get_singleton().get_outdir(),
  247. 'status.json'
  248. )
  249. def __ensure_status_json(self) -> None:
  250. """
  251. Ensures status.json exists and is a valid JSON file.
  252. """
  253. p = Path(self.get_status_json_path())
  254. if not p.exists():
  255. # Ensure parent directory exists
  256. Path.mkdir(p.parent, parents=True, exist_ok=True)
  257. # write empty json dict
  258. with open(p, 'w') as f:
  259. f.write('{}')
  260. def __generate_status_json(self) -> None:
  261. """
  262. Rewrite status.json file.
  263. """
  264. all_build_ids_sorted = sorted(
  265. bm.get_singleton().get_all_build_ids(),
  266. key=lambda x: bm.get_singleton().get_build_info(x).time_created,
  267. reverse=True
  268. )
  269. self.logger.debug(f"All build ids sorted: {all_build_ids_sorted}")
  270. # To-do: fix status.json structure,
  271. # write a list instead of a dict to the file
  272. builds_dict = {}
  273. for build_id in all_build_ids_sorted:
  274. build_info = bm.get_singleton().get_build_info(build_id)
  275. build_age_min = int(time.time() - build_info.time_created) // 60
  276. bi_json = {
  277. 'vehicle': build_info.vehicle.capitalize(),
  278. 'board': build_info.board,
  279. 'git_hash_short': build_info.git_hash[:8],
  280. 'features': ', '.join(build_info.selected_features),
  281. 'status': build_info.progress.state.name,
  282. 'progress': build_info.progress.percent,
  283. 'age': "%u:%02u" % ((build_age_min // 60), build_age_min % 60)
  284. }
  285. self.logger.debug(f"Build info json: {bi_json}")
  286. builds_dict[build_id] = bi_json
  287. self.logger.debug(f"Builds dict: {builds_dict}")
  288. with open(self.get_status_json_path(), 'w') as f:
  289. f.write(json.dumps(builds_dict))
  290. @staticmethod
  291. def get_singleton() -> "BuildProgressUpdater":
  292. """
  293. Get the singleton instance of BuildProgressUpdater.
  294. Returns:
  295. BuildProgressUpdater: The singleton instance of this class.
  296. """
  297. return BuildProgressUpdater.__singleton