builds.py 12 KB

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