builds.py 12 KB

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