builds.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. """
  2. Builds service for handling build-related business logic.
  3. """
  4. import logging
  5. import os
  6. from fastapi import Request
  7. from typing import List, Optional
  8. from web.schemas import (
  9. BuildRequest,
  10. BuildSubmitResponse,
  11. BuildOut,
  12. BuildProgress,
  13. RemoteInfo,
  14. BuildVersionInfo,
  15. )
  16. from web.schemas.vehicles import VehicleBase, BoardBase
  17. # Import external modules
  18. # pylint: disable=wrong-import-position
  19. import build_manager # noqa: E402
  20. logger = logging.getLogger(__name__)
  21. class BuildsService:
  22. """Service for managing firmware builds."""
  23. def __init__(
  24. self,
  25. build_manager=None,
  26. versions_fetcher=None,
  27. ap_src_metadata_fetcher=None,
  28. repo=None,
  29. vehicles_manager=None
  30. ):
  31. self.manager = build_manager
  32. self.versions_fetcher = versions_fetcher
  33. self.ap_src_metadata_fetcher = ap_src_metadata_fetcher
  34. self.repo = repo
  35. self.vehicles_manager = vehicles_manager
  36. def create_build(
  37. self,
  38. build_request: BuildRequest
  39. ) -> BuildSubmitResponse:
  40. """
  41. Create a new build request.
  42. Args:
  43. build_request: Build configuration
  44. Returns:
  45. Simple response with build_id and URL
  46. Raises:
  47. ValueError: If validation fails
  48. """
  49. # Validate version_id
  50. if not build_request.version_id:
  51. raise ValueError("version_id is required")
  52. # Validate vehicle
  53. vehicle_id = build_request.vehicle_id
  54. if not vehicle_id:
  55. raise ValueError("vehicle_id is required")
  56. # Get version info using version_id
  57. version_info = self.versions_fetcher.get_version_info(
  58. vehicle_id=vehicle_id,
  59. version_id=build_request.version_id
  60. )
  61. if version_info is None:
  62. raise ValueError("Invalid version_id for vehicle")
  63. remote_name = version_info.remote_info.name
  64. commit_ref = version_info.commit_ref
  65. # Validate remote
  66. remote_info = self.versions_fetcher.get_remote_info(remote_name)
  67. if remote_info is None:
  68. raise ValueError(f"Remote {remote_name} is not whitelisted")
  69. # Validate board
  70. board_name = build_request.board_id
  71. if not board_name:
  72. raise ValueError("board_id is required")
  73. # Check board exists at this version
  74. with self.repo.get_checkout_lock():
  75. boards_at_commit = self.ap_src_metadata_fetcher.get_boards(
  76. remote=remote_name,
  77. commit_ref=commit_ref,
  78. vehicle_id=vehicle_id,
  79. )
  80. if board_name not in boards_at_commit:
  81. raise ValueError("Invalid board for this version")
  82. # Get git hash
  83. git_hash = self.repo.commit_id_for_remote_ref(
  84. remote=remote_name,
  85. commit_ref=commit_ref
  86. )
  87. # Map feature labels (IDs from API) to defines
  88. # (required by build manager)
  89. selected_feature_defines = set()
  90. if build_request.selected_features:
  91. # Get build options to map labels to defines
  92. with self.repo.get_checkout_lock():
  93. options = (
  94. self.ap_src_metadata_fetcher
  95. .get_build_options_at_commit(
  96. remote=remote_name,
  97. commit_ref=commit_ref
  98. )
  99. )
  100. # Create label to define mapping
  101. label_to_define = {
  102. option.label: option.define for option in options
  103. }
  104. # Map each selected feature label to its define
  105. for feature_label in build_request.selected_features:
  106. if feature_label in label_to_define:
  107. selected_feature_defines.add(
  108. label_to_define[feature_label]
  109. )
  110. else:
  111. logger.warning(
  112. f"Feature label '{feature_label}' not found in "
  113. f"build options for {vehicle_id} {remote_name} "
  114. f"{commit_ref}"
  115. )
  116. # Create build info
  117. build_info = build_manager.BuildInfo(
  118. vehicle_id=vehicle_id,
  119. version_id=build_request.version_id,
  120. remote_info=remote_info,
  121. git_hash=git_hash,
  122. board=board_name,
  123. selected_features=selected_feature_defines
  124. )
  125. # Submit build
  126. build_id = self.manager.submit_build(
  127. build_info=build_info
  128. )
  129. # Return simple submission response
  130. return BuildSubmitResponse(
  131. build_id=build_id,
  132. url=f"/api/v1/builds/{build_id}",
  133. status="submitted"
  134. )
  135. def list_builds(
  136. self,
  137. vehicle_id: Optional[str] = None,
  138. board_id: Optional[str] = None,
  139. state: Optional[str] = None,
  140. limit: int = 20,
  141. offset: int = 0
  142. ) -> List[BuildOut]:
  143. """
  144. Get list of builds with optional filters.
  145. Args:
  146. vehicle_id: Filter by vehicle
  147. board_id: Filter by board
  148. state: Filter by build state
  149. limit: Maximum results
  150. offset: Results to skip
  151. Returns:
  152. List of builds
  153. """
  154. all_build_ids = self.manager.get_all_build_ids()
  155. all_builds = []
  156. for build_id in all_build_ids:
  157. build_info = self.manager.get_build_info(build_id)
  158. if build_info is None:
  159. continue
  160. # Apply filters
  161. if (vehicle_id and
  162. build_info.vehicle_id.lower() != vehicle_id.lower()):
  163. continue
  164. if board_id and build_info.board != board_id:
  165. continue
  166. if state and build_info.progress.state.name != state:
  167. continue
  168. all_builds.append(
  169. self._build_info_to_output(build_id, build_info)
  170. )
  171. # Sort by creation time (newest first)
  172. all_builds.sort(key=lambda x: x.time_created, reverse=True)
  173. # Apply pagination
  174. return all_builds[offset:offset + limit]
  175. def get_build(self, build_id: str) -> Optional[BuildOut]:
  176. """
  177. Get details of a specific build.
  178. Args:
  179. build_id: The unique build identifier
  180. Returns:
  181. Build details or None if not found
  182. """
  183. if not self.manager.build_exists(build_id):
  184. return None
  185. build_info = self.manager.get_build_info(build_id)
  186. if build_info is None:
  187. return None
  188. return self._build_info_to_output(build_id, build_info)
  189. def get_build_logs(
  190. self,
  191. build_id: str,
  192. tail: Optional[int] = None
  193. ) -> Optional[str]:
  194. """
  195. Get build logs for a specific build.
  196. Args:
  197. build_id: The unique build identifier
  198. tail: Optional number of last lines to return
  199. Returns:
  200. Build logs as text or None if not found/available
  201. """
  202. if not self.manager.build_exists(build_id):
  203. return None
  204. log_path = self.manager.get_build_log_path(build_id)
  205. if not os.path.exists(log_path):
  206. return None
  207. try:
  208. with open(log_path, 'r') as f:
  209. if tail:
  210. # Read last N lines
  211. lines = f.readlines()
  212. return ''.join(lines[-tail:])
  213. else:
  214. return f.read()
  215. except Exception as e:
  216. logger.error(f"Error reading log file for build {build_id}: {e}")
  217. return None
  218. def get_artifact_path(self, build_id: str) -> Optional[str]:
  219. """
  220. Get the path to the build artifact.
  221. Args:
  222. build_id: The unique build identifier
  223. Returns:
  224. Path to artifact or None if not available
  225. """
  226. if not self.manager.build_exists(build_id):
  227. return None
  228. build_info = self.manager.get_build_info(build_id)
  229. if build_info is None:
  230. return None
  231. # Return early if build is still ongoing
  232. if build_info.progress.state in [
  233. build_manager.BuildState.PENDING,
  234. build_manager.BuildState.RUNNING,
  235. ]:
  236. return None
  237. artifact_path = self.manager.get_build_archive_path(build_id)
  238. if os.path.exists(artifact_path):
  239. return artifact_path
  240. return None
  241. def _build_info_to_output(
  242. self,
  243. build_id: str,
  244. build_info
  245. ) -> BuildOut:
  246. """
  247. Convert BuildInfo object to BuildOut schema.
  248. Args:
  249. build_id: The build identifier
  250. build_info: BuildInfo object from build_manager
  251. Returns:
  252. BuildOut schema object
  253. """
  254. # Convert build_manager.BuildProgress to schema BuildProgress
  255. progress = BuildProgress(
  256. percent=build_info.progress.percent,
  257. state=build_info.progress.state.name
  258. )
  259. # Convert RemoteInfo
  260. remote_info = RemoteInfo(
  261. name=build_info.remote_info.name,
  262. url=build_info.remote_info.url
  263. )
  264. # Map feature defines back to labels for API response
  265. selected_feature_labels = []
  266. if build_info.selected_features:
  267. try:
  268. # Get build options to map defines back to labels
  269. with self.repo.get_checkout_lock():
  270. options = (
  271. self.ap_src_metadata_fetcher
  272. .get_build_options_at_commit(
  273. remote=build_info.remote_info.name,
  274. commit_ref=build_info.git_hash
  275. )
  276. )
  277. # Create define to label mapping
  278. define_to_label = {
  279. option.define: option.label for option in options
  280. }
  281. # Map each selected feature define to its label
  282. for feature_define in build_info.selected_features:
  283. if feature_define in define_to_label:
  284. selected_feature_labels.append(
  285. define_to_label[feature_define]
  286. )
  287. else:
  288. # Fallback: use define if label not found
  289. logger.warning(
  290. f"Feature define '{feature_define}' not "
  291. f"found in build options for build "
  292. f"{build_id}"
  293. )
  294. selected_feature_labels.append(feature_define)
  295. except Exception as e:
  296. logger.error(
  297. f"Error mapping feature defines to labels for "
  298. f"build {build_id}: {e}"
  299. )
  300. # Fallback: use defines as-is
  301. selected_feature_labels = list(
  302. build_info.selected_features
  303. )
  304. vehicle = self.vehicles_manager.get_vehicle_by_id(
  305. build_info.vehicle_id
  306. )
  307. return BuildOut(
  308. build_id=build_id,
  309. vehicle=VehicleBase(
  310. id=build_info.vehicle_id,
  311. name=vehicle.name
  312. ),
  313. board=BoardBase(
  314. id=build_info.board,
  315. name=build_info.board # Board name is same as board ID for now
  316. ),
  317. version=BuildVersionInfo(
  318. id=build_info.version_id,
  319. remote_info=remote_info,
  320. git_hash=build_info.git_hash
  321. ),
  322. selected_features=selected_feature_labels,
  323. progress=progress,
  324. time_created=build_info.time_created,
  325. )
  326. def get_builds_service(request: Request) -> BuildsService:
  327. """
  328. Get BuildsService instance with dependencies from app state.
  329. Args:
  330. request: FastAPI Request object
  331. Returns:
  332. BuildsService instance initialized with app state dependencies
  333. """
  334. return BuildsService(
  335. build_manager=request.app.state.build_manager,
  336. versions_fetcher=request.app.state.versions_fetcher,
  337. ap_src_metadata_fetcher=request.app.state.ap_src_metadata_fetcher,
  338. repo=request.app.state.repo,
  339. vehicles_manager=request.app.state.vehicles_manager,
  340. )