progress_updater.py 11 KB

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