builds.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. from typing import List, Optional
  2. from fastapi import (
  3. APIRouter,
  4. HTTPException,
  5. Query,
  6. Path,
  7. status,
  8. Depends,
  9. Request
  10. )
  11. from fastapi.responses import FileResponse, PlainTextResponse
  12. from schemas import (
  13. BuildRequest,
  14. BuildSubmitResponse,
  15. BuildOut,
  16. )
  17. from services.builds import get_builds_service, BuildsService
  18. from utils import RateLimitExceededException
  19. router = APIRouter(prefix="/builds", tags=["builds"])
  20. @router.post(
  21. "",
  22. response_model=BuildSubmitResponse,
  23. status_code=status.HTTP_201_CREATED,
  24. responses={
  25. 400: {"description": "Invalid build configuration"},
  26. 404: {"description": "Vehicle, board, or version not found"},
  27. 429: {"description": "Rate limit exceeded"}
  28. }
  29. )
  30. async def create_build(
  31. build_request: BuildRequest,
  32. request: Request,
  33. service: BuildsService = Depends(get_builds_service)
  34. ):
  35. """
  36. Create a new build request.
  37. Args:
  38. build_request: Build configuration including vehicle, board, version,
  39. and selected features
  40. Returns:
  41. Simple response with build_id, URL, and status
  42. Raises:
  43. 400: Invalid build configuration
  44. 404: Vehicle, board, or version not found
  45. 429: Rate limit exceeded
  46. """
  47. try:
  48. # Get client IP for rate limiting
  49. forwarded_for = request.headers.get('X-Forwarded-For', None)
  50. if forwarded_for:
  51. client_ip = forwarded_for.split(',')[0].strip()
  52. else:
  53. client_ip = request.client.host if request.client else "unknown"
  54. return service.create_build(build_request, client_ip)
  55. except RateLimitExceededException as e:
  56. raise HTTPException(
  57. status_code=status.HTTP_429_TOO_MANY_REQUESTS,
  58. detail=str(e)
  59. )
  60. except ValueError as e:
  61. raise HTTPException(status_code=400, detail=str(e))
  62. except Exception as e:
  63. raise HTTPException(status_code=400, detail=str(e))
  64. @router.get("", response_model=List[BuildOut])
  65. async def list_builds(
  66. vehicle_id: Optional[str] = Query(
  67. None, description="Filter by vehicle ID"
  68. ),
  69. board_id: Optional[str] = Query(
  70. None, description="Filter by board ID"
  71. ),
  72. state: Optional[str] = Query(
  73. None,
  74. description="Filter by build state (PENDING, RUNNING, SUCCESS, "
  75. "FAILURE, CANCELLED)"
  76. ),
  77. limit: int = Query(
  78. 20, ge=1, le=100, description="Maximum number of builds to return"
  79. ),
  80. offset: int = Query(
  81. 0, ge=0, description="Number of builds to skip"
  82. ),
  83. service: BuildsService = Depends(get_builds_service)
  84. ):
  85. """
  86. Get list of builds with optional filters.
  87. Args:
  88. vehicle_id: Filter builds by vehicle
  89. board_id: Filter builds by board
  90. state: Filter builds by current state
  91. limit: Maximum number of results
  92. offset: Number of results to skip (for pagination)
  93. Returns:
  94. List of builds matching the filters
  95. """
  96. return service.list_builds(
  97. vehicle_id=vehicle_id,
  98. board_id=board_id,
  99. state=state,
  100. limit=limit,
  101. offset=offset
  102. )
  103. @router.get(
  104. "/{build_id}",
  105. response_model=BuildOut,
  106. responses={
  107. 404: {"description": "Build not found"}
  108. }
  109. )
  110. async def get_build(
  111. build_id: str = Path(..., description="Unique build identifier"),
  112. service: BuildsService = Depends(get_builds_service)
  113. ):
  114. """
  115. Get details of a specific build.
  116. Args:
  117. build_id: The unique build identifier
  118. Returns:
  119. Complete build details including progress and status
  120. Raises:
  121. 404: Build not found
  122. """
  123. build = service.get_build(build_id)
  124. if not build:
  125. raise HTTPException(
  126. status_code=404,
  127. detail=f"Build with id '{build_id}' not found"
  128. )
  129. return build
  130. @router.get(
  131. "/{build_id}/logs",
  132. responses={
  133. 404: {"description": "Build not found or logs not available yet"}
  134. }
  135. )
  136. async def get_build_logs(
  137. build_id: str = Path(..., description="Unique build identifier"),
  138. tail: Optional[int] = Query(
  139. None, ge=1, description="Return only the last N lines"
  140. ),
  141. service: BuildsService = Depends(get_builds_service)
  142. ):
  143. """
  144. Get build logs for a specific build.
  145. Args:
  146. build_id: The unique build identifier
  147. tail: Optional number of last lines to return
  148. Returns:
  149. Build logs as text
  150. Raises:
  151. 404: Build not found
  152. 404: Logs not available yet
  153. """
  154. logs = service.get_build_logs(build_id, tail)
  155. if logs is None:
  156. raise HTTPException(
  157. status_code=404,
  158. detail=f"Logs not available for build '{build_id}'"
  159. )
  160. return PlainTextResponse(content=logs)
  161. @router.get(
  162. "/{build_id}/artifact",
  163. responses={
  164. 404: {
  165. "description": (
  166. "Build not found or artifact not available "
  167. )
  168. }
  169. }
  170. )
  171. async def download_artifact(
  172. build_id: str = Path(..., description="Unique build identifier"),
  173. service: BuildsService = Depends(get_builds_service)
  174. ):
  175. """
  176. Download the build artifact (firmware binary).
  177. Args:
  178. build_id: The unique build identifier
  179. Returns:
  180. Binary file download
  181. Raises:
  182. 404: Build not found
  183. 404: Artifact not available (build not completed successfully)
  184. """
  185. artifact_path = service.get_artifact_path(build_id)
  186. if not artifact_path:
  187. raise HTTPException(
  188. status_code=404,
  189. detail=(
  190. f"Artifact not available for build '{build_id}'. "
  191. "Build may not be completed or successful."
  192. )
  193. )
  194. return FileResponse(
  195. path=artifact_path,
  196. media_type='application/gzip',
  197. filename=f"{build_id}.tar.gz"
  198. )